From a8bedfc2fc1247b652d727d0260666f47ff798d9 Mon Sep 17 00:00:00 2001 From: Akash Date: Mon, 9 Mar 2026 22:12:59 +0530 Subject: [PATCH 1/2] standardizied the output pipline --- .../Windows/PowerShell/ModuleAnalysisCache | Bin 0 -> 58254 bytes osipy/__init__.py | 5 +- osipy/cli/runner.py | 4 +- osipy/common/__init__.py | 3 + osipy/common/io/bids.py | 4 +- osipy/common/logging.py | 4 +- osipy/common/types.py | 35 ++ osipy/common/validation/report.py | 4 +- osipy/dce/models/base.py | 4 +- osipy/pipeline/__init__.py | 3 +- osipy/pipeline/runner.py | 180 ++++++-- osipy/scripts/capture_current_state.py | 402 ++++++++++++++++++ osipy/scripts/state_before_refactor.json | 82 ++++ pyproject.toml | 2 +- tests/integration/test_pipeline.py | 86 +++- 15 files changed, 748 insertions(+), 70 deletions(-) create mode 100644 Microsoft/Windows/PowerShell/ModuleAnalysisCache create mode 100644 osipy/scripts/capture_current_state.py create mode 100644 osipy/scripts/state_before_refactor.json diff --git a/Microsoft/Windows/PowerShell/ModuleAnalysisCache b/Microsoft/Windows/PowerShell/ModuleAnalysisCache new file mode 100644 index 0000000000000000000000000000000000000000..987d010f2452d59248bd0c73b5817d2fb7e881c5 GIT binary patch literal 58254 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 literal 0 HcmV?d00001 diff --git a/osipy/__init__.py b/osipy/__init__.py index 447f2b0..8921e0a 100644 --- a/osipy/__init__.py +++ b/osipy/__init__.py @@ -82,7 +82,7 @@ from osipy.common.dataset import PerfusionDataset from osipy.common.io import export_bids, load_dicom, load_nifti, load_perfusion from osipy.common.parameter_map import ParameterMap - from osipy.common.types import AIFType, FittingMethod, LabelingType, Modality + from osipy.common.types import AIFType, AnalysisResult, FittingMethod, LabelingType, Modality from osipy.dce import ( ExtendedToftsModel, PatlakModel, @@ -122,6 +122,8 @@ "ASLPipeline", # AIF "ArterialInputFunction", + # Result contract + "AnalysisResult", # Pipelines "DCEPipeline", "DSCPipeline", @@ -217,6 +219,7 @@ "ParameterMap": ("osipy.common.parameter_map", "ParameterMap"), # Types "AIFType": ("osipy.common.types", "AIFType"), + "AnalysisResult": ("osipy.common.types", "AnalysisResult"), "FittingMethod": ("osipy.common.types", "FittingMethod"), "LabelingType": ("osipy.common.types", "LabelingType"), "Modality": ("osipy.common.types", "Modality"), diff --git a/osipy/cli/runner.py b/osipy/cli/runner.py index 5f3fa06..c43579f 100644 --- a/osipy/cli/runner.py +++ b/osipy/cli/runner.py @@ -9,7 +9,7 @@ import json import logging import time -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any @@ -1005,7 +1005,7 @@ def _save_metadata( metadata: dict[str, Any] = { "osipy_version": __version__, "modality": config.modality, - "timestamp": datetime.now(tz=UTC).isoformat(), + "timestamp": datetime.now(tz=timezone.utc).isoformat(), "data_path": str(data_path), "config": config.model_dump(), } diff --git a/osipy/common/__init__.py b/osipy/common/__init__.py index 106e658..5d6fe58 100644 --- a/osipy/common/__init__.py +++ b/osipy/common/__init__.py @@ -59,6 +59,7 @@ AcquisitionParams, AIFType, ASLAcquisitionParams, + AnalysisResult, DCEAcquisitionParams, DSCAcquisitionParams, FittingMethod, @@ -73,6 +74,8 @@ "ASLAcquisitionParams", # Acquisition parameters "AcquisitionParams", + # Result contract + "AnalysisResult", "DCEAcquisitionParams", "DSCAcquisitionParams", "DataValidationError", diff --git a/osipy/common/io/bids.py b/osipy/common/io/bids.py index 996e850..0651187 100644 --- a/osipy/common/io/bids.py +++ b/osipy/common/io/bids.py @@ -13,7 +13,7 @@ import csv import json import logging -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -160,7 +160,7 @@ def export_bids( "Name": "osipy", "Version": __version__, }, - "Timestamp": datetime.now(UTC).isoformat(), + "Timestamp": datetime.now(timezone.utc).isoformat(), "Parameters": {}, } diff --git a/osipy/common/logging.py b/osipy/common/logging.py index 0f5c82c..59a71dc 100644 --- a/osipy/common/logging.py +++ b/osipy/common/logging.py @@ -8,7 +8,7 @@ import json import logging import sys -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import TextIO @@ -34,7 +34,7 @@ def format(self, record: logging.LogRecord) -> str: JSON-formatted log entry. """ log_entry = { - "timestamp": datetime.now(UTC).isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "level": record.levelname, "logger": record.name, "module": record.module, diff --git a/osipy/common/types.py b/osipy/common/types.py index b29d7ee..7db6983 100644 --- a/osipy/common/types.py +++ b/osipy/common/types.py @@ -256,3 +256,38 @@ class IVIMAcquisitionParams(AcquisitionParams): | ASLAcquisitionParams | IVIMAcquisitionParams ) + + +@dataclass +class AnalysisResult: + """Standardised pipeline output contract. + + Every call to :func:`osipy.pipeline.run_analysis` returns this object, + regardless of modality. Downstream consumers (CLI tools, batch scripts, + visualisation code) only need to know this single class — they never have + to branch on modality to extract parameter maps or masks. + + Attributes + ---------- + modality : Modality + Source pipeline (DCE, DSC, ASL, or IVIM). + parameter_maps : dict[str, ParameterMap] + All output parameter maps keyed by OSIPI CAPLEX name + (e.g. ``'Ktrans'``, ``'CBF'``, ``'D'``). + **Guaranteed**: always a non-empty dict, never ``None``. + quality_mask : NDArray[np.bool_] + Boolean array marking valid voxels, same spatial shape as + the parameter maps. **Guaranteed**: always present, never ``None``. + provenance : dict[str, Any] + Audit trail for reproducibility. Contains at minimum: + + * ``osipy_version`` – library version string + * ``captured_at`` – ISO-8601 UTC timestamp of the analysis run + * ``modality`` – modality value string + * ``config`` – serialised pipeline configuration dict + """ + + modality: Modality + parameter_maps: "dict[str, ParameterMap]" + quality_mask: "NDArray[np.bool_]" + provenance: "dict[str, Any]" = field(default_factory=dict) diff --git a/osipy/common/validation/report.py b/osipy/common/validation/report.py index 3830e19..9e80bfd 100644 --- a/osipy/common/validation/report.py +++ b/osipy/common/validation/report.py @@ -12,7 +12,7 @@ import json as _json from dataclasses import dataclass, field -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any @@ -186,7 +186,7 @@ def to_dict(self) -> dict[str, Any]: "tolerances": { k: v for k, v in self.tolerances.items() if k in self.parameters }, - "timestamp": datetime.now(UTC).isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "version": getattr(osipy, "__version__", "unknown"), } diff --git a/osipy/dce/models/base.py b/osipy/dce/models/base.py index 6d50862..0096f85 100644 --- a/osipy/dce/models/base.py +++ b/osipy/dce/models/base.py @@ -12,7 +12,7 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from osipy.common.backend.array_module import get_array_module, to_numpy from osipy.common.models.base import BaseSignalModel @@ -32,7 +32,7 @@ class ModelParameters: P = TypeVar("P", bound=ModelParameters) -class BasePerfusionModel[P: ModelParameters](BaseSignalModel): +class BasePerfusionModel(BaseSignalModel, Generic[P]): """Abstract base class for perfusion pharmacokinetic models. All DCE/DSC models must inherit from this class and implement diff --git a/osipy/pipeline/__init__.py b/osipy/pipeline/__init__.py index 22671da..975077b 100644 --- a/osipy/pipeline/__init__.py +++ b/osipy/pipeline/__init__.py @@ -29,11 +29,12 @@ from osipy.pipeline.dce_pipeline import DCEPipeline, DCEPipelineConfig from osipy.pipeline.dsc_pipeline import DSCPipeline, DSCPipelineConfig from osipy.pipeline.ivim_pipeline import IVIMPipeline, IVIMPipelineConfig -from osipy.pipeline.runner import PipelineResult, run_analysis +from osipy.pipeline.runner import AnalysisResult, PipelineResult, run_analysis __all__ = [ "ASLPipeline", "ASLPipelineConfig", + "AnalysisResult", "DCEPipeline", "DCEPipelineConfig", "DSCPipeline", diff --git a/osipy/pipeline/runner.py b/osipy/pipeline/runner.py index 159efce..c002b95 100644 --- a/osipy/pipeline/runner.py +++ b/osipy/pipeline/runner.py @@ -4,6 +4,10 @@ pipelines with automatic modality detection. """ +from __future__ import annotations + +import dataclasses +import datetime from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -11,7 +15,7 @@ from osipy.common.exceptions import DataValidationError from osipy.common.parameter_map import ParameterMap -from osipy.common.types import Modality +from osipy.common.types import AnalysisResult, Modality if TYPE_CHECKING: from numpy.typing import NDArray @@ -19,7 +23,12 @@ @dataclass class PipelineResult: - """Generic pipeline result container. + """Generic pipeline result container (internal). + + This class is kept for backwards compatibility with code that calls + individual ``*Pipeline.run()`` methods directly. The public-facing + unified result type returned by :func:`run_analysis` is + :class:`~osipy.common.types.AnalysisResult`. Attributes ---------- @@ -39,15 +48,82 @@ class PipelineResult: metadata: dict[str, Any] = field(default_factory=dict) +# --------------------------------------------------------------------------- +# Provenance helpers +# --------------------------------------------------------------------------- + +def _serialise_config(config: Any) -> dict[str, Any]: + """Convert a pipeline config dataclass to a JSON-safe dict.""" + if config is None: + return {} + if dataclasses.is_dataclass(config): + raw = dataclasses.asdict(config) + elif hasattr(config, "__dict__"): + raw = dict(vars(config)) + else: + return {"repr": str(config)} + + # Make values JSON-safe (convert non-primitives to str) + def _safe(v: Any) -> Any: + if isinstance(v, (str, int, float, bool, type(None))): + return v + if isinstance(v, dict): + return {k: _safe(vv) for k, vv in v.items()} + if isinstance(v, (list, tuple)): + return [_safe(i) for i in v] + return str(v) + + return {k: _safe(v) for k, v in raw.items()} + + +def _make_provenance( + modality: Modality, + config: Any, + extra_metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the provenance dict injected into every AnalysisResult.""" + from osipy._version import __version__ + + prov: dict[str, Any] = { + "osipy_version": __version__, + "captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ), + "modality": modality.value, + "config": _serialise_config(config), + } + if extra_metadata: + prov.update(extra_metadata) + return prov + + +def _pipeline_result_to_analysis_result( + pr: PipelineResult, + provenance: dict[str, Any], +) -> AnalysisResult: + """Wrap a PipelineResult in the public AnalysisResult contract.""" + return AnalysisResult( + modality=pr.modality, + parameter_maps=pr.parameter_maps, + quality_mask=pr.quality_mask, + provenance=provenance, + ) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + def run_analysis( data: "NDArray[np.floating[Any]]", modality: Modality | str, **kwargs: Any, -) -> PipelineResult: +) -> AnalysisResult: """Run perfusion analysis with specified modality. This is a unified entry point that dispatches to the appropriate - pipeline based on the modality. + pipeline based on the modality and returns a standardised + :class:`~osipy.common.types.AnalysisResult` regardless of modality. Parameters ---------- @@ -61,8 +137,12 @@ def run_analysis( Returns ------- - PipelineResult - Analysis results. + AnalysisResult + Standardised analysis result with: + + * ``parameter_maps`` – dict of named :class:`~osipy.common.parameter_map.ParameterMap` + * ``quality_mask`` – boolean voxel-validity array (guaranteed non-None) + * ``provenance`` – audit trail (version, timestamp, config) Examples -------- @@ -78,14 +158,13 @@ def run_analysis( ... model='extended_tofts', ... ) >>> - >>> # Run IVIM analysis - >>> result = run_analysis( - ... dwi_data, - ... modality='ivim', - ... b_values=b_values, - ... ) + >>> # Uniform interface – works for every modality + >>> for name, pmap in result.parameter_maps.items(): + ... print(name, pmap.values.shape) + >>> result.quality_mask # always a bool ndarray + >>> result.provenance['osipy_version'] # reproducibility audit trail """ - # Normalize modality + # Normalise modality if isinstance(modality, str): modality_map = { "dce": Modality.DCE, @@ -108,13 +187,16 @@ def run_analysis( raise DataValidationError(msg) +# --------------------------------------------------------------------------- +# Private per-modality helpers +# --------------------------------------------------------------------------- + def _run_dce_analysis( data: "NDArray[np.floating[Any]]", **kwargs: Any -) -> PipelineResult: +) -> AnalysisResult: """Run DCE-MRI analysis.""" from osipy.pipeline.dce_pipeline import DCEPipeline, DCEPipelineConfig - # Extract configuration options model = kwargs.pop("model", "extended_tofts") aif_source = kwargs.pop("aif_source", "population") @@ -126,7 +208,7 @@ def _run_dce_analysis( pipeline = DCEPipeline(config) result = pipeline.run(data, **kwargs) - return PipelineResult( + pr = PipelineResult( modality=Modality.DCE, parameter_maps=result.fit_result.parameter_maps, quality_mask=result.fit_result.quality_mask, @@ -135,11 +217,14 @@ def _run_dce_analysis( "fitting_stats": result.fit_result.fitting_stats, }, ) + return _pipeline_result_to_analysis_result( + pr, _make_provenance(Modality.DCE, config) + ) def _run_dsc_analysis( data: "NDArray[np.floating[Any]]", **kwargs: Any -) -> PipelineResult: +) -> AnalysisResult: """Run DSC-MRI analysis.""" from osipy.pipeline.dsc_pipeline import DSCPipeline, DSCPipelineConfig @@ -154,8 +239,8 @@ def _run_dsc_analysis( pipeline = DSCPipeline(config) result = pipeline.run(data, **kwargs) - # Extract parameter maps - param_maps = { + # Extract parameter maps into a uniform dict + param_maps: dict[str, ParameterMap] = { "CBV": result.perfusion_maps.cbv, "CBF": result.perfusion_maps.cbf, "MTT": result.perfusion_maps.mtt, @@ -163,19 +248,30 @@ def _run_dsc_analysis( if result.perfusion_maps.ttp is not None: param_maps["TTP"] = result.perfusion_maps.ttp - return PipelineResult( + # Guarantee quality mask is never None + if result.perfusion_maps.quality_mask is not None: + qm = result.perfusion_maps.quality_mask + quality_mask = qm.values if hasattr(qm, "values") else qm + else: + # DSC had no mask — default to all-valid + first_map = next(iter(param_maps.values())) + arr = first_map.values if hasattr(first_map, "values") else first_map + quality_mask = np.ones(arr.shape, dtype=bool) + + pr = PipelineResult( modality=Modality.DSC, parameter_maps=param_maps, - quality_mask=result.perfusion_maps.quality_mask - if result.perfusion_maps.quality_mask is not None - else np.ones(result.perfusion_maps.cbv.data.shape, dtype=bool), + quality_mask=quality_mask, metadata={}, ) + return _pipeline_result_to_analysis_result( + pr, _make_provenance(Modality.DSC, config) + ) def _run_asl_analysis( data: "NDArray[np.floating[Any]]", **kwargs: Any -) -> PipelineResult: +) -> AnalysisResult: """Run ASL analysis.""" from osipy.asl import LabelingScheme from osipy.pipeline.asl_pipeline import ASLPipeline, ASLPipelineConfig @@ -190,25 +286,30 @@ def _run_asl_analysis( pipeline = ASLPipeline(config) - # Handle different input formats if "control_data" in kwargs: result = pipeline.run(data, **kwargs) else: - # Assume interleaved data m0_data = kwargs.pop("m0_data", 1.0) result = pipeline.run_from_alternating(data, m0_data, **kwargs) - return PipelineResult( + cbf_map = result.cbf_result.cbf_map + qm = result.cbf_result.quality_mask + quality_mask = qm.values if hasattr(qm, "values") else qm + + pr = PipelineResult( modality=Modality.ASL, - parameter_maps={"CBF": result.cbf_result.cbf_map}, - quality_mask=result.cbf_result.quality_mask, + parameter_maps={"CBF": cbf_map}, + quality_mask=quality_mask, metadata={}, ) + return _pipeline_result_to_analysis_result( + pr, _make_provenance(Modality.ASL, config) + ) def _run_ivim_analysis( data: "NDArray[np.floating[Any]]", **kwargs: Any -) -> PipelineResult: +) -> AnalysisResult: """Run IVIM analysis.""" from osipy.pipeline.ivim_pipeline import IVIMPipeline, IVIMPipelineConfig @@ -216,13 +317,20 @@ def _run_ivim_analysis( pipeline = IVIMPipeline(config) result = pipeline.run(data, **kwargs) - return PipelineResult( + fr = result.fit_result + qm = fr.quality_mask + quality_mask = qm.values if hasattr(qm, "values") else qm + + pr = PipelineResult( modality=Modality.IVIM, parameter_maps={ - "D": result.fit_result.d_map, - "D*": result.fit_result.d_star_map, - "f": result.fit_result.f_map, + "D": fr.d_map, + "D*": fr.d_star_map, + "f": fr.f_map, }, - quality_mask=result.fit_result.quality_mask, - metadata=result.fit_result.fitting_stats, + quality_mask=quality_mask, + metadata=fr.fitting_stats, + ) + return _pipeline_result_to_analysis_result( + pr, _make_provenance(Modality.IVIM, config) ) diff --git a/osipy/scripts/capture_current_state.py b/osipy/scripts/capture_current_state.py new file mode 100644 index 0000000..8800dec --- /dev/null +++ b/osipy/scripts/capture_current_state.py @@ -0,0 +1,402 @@ +"""capture_current_state.py +=========================== +**Before-the-refactor snapshot tool for osipy pipelines.** + +Run this script *before* the AnalysisResult refactor to document +(and optionally save) the exact shapes and access paths of every +result class today. After the refactor, running the script again +lets you compare old vs. new side-by-side. + +Usage +----- +From the repo root (activate your venv first!): + + python -m osipy.scripts.capture_current_state + # or, to save the snapshot as JSON: + python -m osipy.scripts.capture_current_state --save + +The script uses tiny synthetic data (4x4x2 voxels, few time-points) +so it runs in seconds without real MRI data. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import sys +import textwrap +from pathlib import Path +from typing import Any + +import numpy as np + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────────── + +SEP = "=" * 72 +INDENT = " " + + +def _banner(title: str) -> None: + print(f"\n{SEP}") + print(f" {title}") + print(SEP) + + +def _field(label: str, value: Any, depth: int = 1) -> None: + pad = INDENT * depth + print(f"{pad}{label}: {value}") + + +def _describe_array(arr: np.ndarray | None, label: str = "array") -> str: + if arr is None: + return "None" + return f"ndarray shape={arr.shape} dtype={arr.dtype}" + + +def _describe_dict(d: dict, label: str = "") -> None: + if not d: + print(f"{INDENT * 2}(empty)") + return + for k, v in d.items(): + if hasattr(v, "values"): # ParameterMap + _field( + f'["{k}"]', + f"ParameterMap name={v.name!r} shape={v.values.shape}" + f" units={v.units!r}", + depth=2, + ) + elif isinstance(v, np.ndarray): + _field(f'["{k}"]', _describe_array(v), depth=2) + else: + _field(f'["{k}"]', repr(v), depth=2) + + +# ────────────────────────────────────────────────────────────────────────────── +# Synthetic data factories +# ────────────────────────────────────────────────────────────────────────────── + +SHAPE_3D = (4, 4, 2) # spatial voxels +N_TIMEPOINTS = 12 +N_B_VALUES = 6 + +rng = np.random.default_rng(42) + + +def _make_dce_data() -> tuple[np.ndarray, np.ndarray]: + """Returns (signal [4,4,2,T], time [T]).""" + signal = rng.uniform(0.8, 1.2, (*SHAPE_3D, N_TIMEPOINTS)).astype(np.float64) + time = np.linspace(0, 60, N_TIMEPOINTS) + return signal, time + + +def _make_dsc_data() -> tuple[np.ndarray, np.ndarray]: + signal = rng.uniform(800.0, 1200.0, (*SHAPE_3D, N_TIMEPOINTS)).astype(np.float64) + time = np.linspace(0, 60, N_TIMEPOINTS) + return signal, time + + +def _make_asl_data() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + label = rng.uniform(400.0, 600.0, (*SHAPE_3D, 4)).astype(np.float64) + control = label + rng.uniform(10.0, 30.0, label.shape) + m0 = np.full(SHAPE_3D, 1000.0) + return label, control, m0 + + +def _make_ivim_data() -> tuple[np.ndarray, np.ndarray]: + b_values = np.array([0, 10, 20, 50, 100, 200], dtype=np.float64) + signal = np.zeros((*SHAPE_3D, N_B_VALUES)) + for i, b in enumerate(b_values): + signal[..., i] = np.exp(-0.001 * b) * 1000.0 + signal += rng.normal(0, 5.0, signal.shape) + return signal, b_values + + +# ────────────────────────────────────────────────────────────────────────────── +# Per-pipeline snapshots +# ────────────────────────────────────────────────────────────────────────────── + +SnapshotDict = dict[str, Any] + + +def _snapshot_dce() -> SnapshotDict: + """Run DCEPipeline and inspect DCEPipelineResult.""" + _banner("DCE Pipeline -> DCEPipelineResult") + + from osipy.pipeline.dce_pipeline import DCEPipeline, DCEPipelineConfig + + signal, time = _make_dce_data() + config = DCEPipelineConfig(model="tofts", aif_source="population") + pipeline = DCEPipeline(config) + result = pipeline.run(signal, time) + + print(f"\n Result class : {type(result).__name__}") + print(f" Module : {type(result).__module__}") + + print(f"\n +-- result.fit_result ({type(result.fit_result).__name__})") + print(f" | .parameter_maps (dict):") + _describe_dict(result.fit_result.parameter_maps) + print(f" | .quality_mask : {_describe_array(result.fit_result.quality_mask)}") + print(f" | .model_name : {result.fit_result.model_name!r}") + + print(f"\n +-- result.t1_map : {result.t1_map}") + print(f" +-- result.aif : {type(result.aif).__name__}") + print(f" +-- result.config.model : {result.config.model!r}") + + print("\n ! Access path for Ktrans: result.fit_result.parameter_maps['Ktrans']") + print(" ! Access path for mask: result.fit_result.quality_mask") + + return { + "class": type(result).__name__, + "parameter_maps_keys": list(result.fit_result.parameter_maps.keys()), + "quality_mask_shape": list(result.fit_result.quality_mask.shape), + "model": result.fit_result.model_name, + "access_path_params": "result.fit_result.parameter_maps[name]", + "access_path_mask": "result.fit_result.quality_mask", + "mask_guaranteed": True, + } + + +def _snapshot_dsc() -> SnapshotDict: + """Run DSCPipeline and inspect DSCPipelineResult.""" + _banner("DSC Pipeline -> DSCPipelineResult") + + from osipy.pipeline.dsc_pipeline import DSCPipeline, DSCPipelineConfig + + signal, time = _make_dsc_data() + config = DSCPipelineConfig(te=30.0, apply_leakage_correction=False) + pipeline = DSCPipeline(config) + result = pipeline.run(signal, time) + + print(f"\n Result class : {type(result).__name__}") + + pm = result.perfusion_maps + print(f"\n +-- result.perfusion_maps ({type(pm).__name__})") + print(f" | .cbv : {_describe_array(pm.cbv.values if hasattr(pm.cbv,'values') else pm.cbv)}") + print(f" | .cbf : {_describe_array(pm.cbf.values if hasattr(pm.cbf,'values') else pm.cbf)}") + print(f" | .mtt : {_describe_array(pm.mtt.values if hasattr(pm.mtt,'values') else pm.mtt)}") + + qm = pm.quality_mask + qm_val = qm.values if hasattr(qm, "values") else qm + print(f" | .quality_mask : {_describe_array(qm_val)}") + + is_none = (qm is None) + print(f" +-- quality_mask is None? {is_none}") + print("\n ! Access path for CBF: result.perfusion_maps.cbf") + print(" ! Access path for mask: result.perfusion_maps.quality_mask (CAN BE None)") + print(" ! No dict interface – every map is a separate attribute!") + + return { + "class": type(result).__name__, + "parameter_maps_keys": ["cbv", "cbf", "mtt", "ttp"], + "quality_mask_shape": list(qm_val.shape) if qm_val is not None else None, + "access_path_params": "result.perfusion_maps. (individual attrs)", + "access_path_mask": "result.perfusion_maps.quality_mask", + "mask_guaranteed": False, + } + + +def _snapshot_asl() -> SnapshotDict: + """Run ASLPipeline and inspect ASLPipelineResult.""" + _banner("ASL Pipeline -> ASLPipelineResult") + + from osipy.asl import LabelingScheme + from osipy.pipeline.asl_pipeline import ASLPipeline, ASLPipelineConfig + + label, control, m0 = _make_asl_data() + config = ASLPipelineConfig( + labeling_scheme=LabelingScheme.PCASL, pld=1800.0 + ) + pipeline = ASLPipeline(config) + result = pipeline.run(label, control, m0) + + cbf = result.cbf_result + print(f"\n Result class : {type(result).__name__}") + print(f"\n +-- result.cbf_result ({type(cbf).__name__})") + + cbf_map = cbf.cbf_map + cbf_arr = cbf_map.values if hasattr(cbf_map, "values") else cbf_map + print(f" | .cbf_map : {_describe_array(cbf_arr)}") + + qm = cbf.quality_mask + qm_arr = qm.values if hasattr(qm, "values") else qm + print(f" | .quality_mask : {_describe_array(qm_arr)}") + print(f" +-- result.m0_map : {result.m0_map}") + + print("\n ! Access path for CBF: result.cbf_result.cbf_map") + print(" ! Access path for mask: result.cbf_result.quality_mask") + print(" ! Not a dict – only one parameter (CBF)") + + return { + "class": type(result).__name__, + "parameter_maps_keys": ["cbf_map"], + "quality_mask_shape": list(qm_arr.shape) if qm_arr is not None else None, + "access_path_params": "result.cbf_result.cbf_map", + "access_path_mask": "result.cbf_result.quality_mask", + "mask_guaranteed": True, + } + + +def _snapshot_ivim() -> SnapshotDict: + """Run IVIMPipeline and inspect IVIMPipelineResult.""" + _banner("IVIM Pipeline -> IVIMPipelineResult") + + from osipy.pipeline.ivim_pipeline import IVIMPipeline, IVIMPipelineConfig + + signal, b_values = _make_ivim_data() + config = IVIMPipelineConfig() + pipeline = IVIMPipeline(config) + result = pipeline.run(signal, b_values) + + fr = result.fit_result + print(f"\n Result class : {type(result).__name__}") + print(f"\n +-- result.fit_result ({type(fr).__name__})") + + d_arr = fr.d_map.values if hasattr(fr.d_map, "values") else fr.d_map + ds_arr = fr.d_star_map.values if hasattr(fr.d_star_map, "values") else fr.d_star_map + f_arr = fr.f_map.values if hasattr(fr.f_map, "values") else fr.f_map + qm_arr = fr.quality_mask.values if hasattr(fr.quality_mask, "values") else fr.quality_mask + + print(f" | .d_map : {_describe_array(d_arr)}") + print(f" | .d_star_map : {_describe_array(ds_arr)}") + print(f" | .f_map : {_describe_array(f_arr)}") + print(f" | .quality_mask: {_describe_array(qm_arr)}") + + print("\n ! Access path for D: result.fit_result.d_map") + print(" ! Access path for D*: result.fit_result.d_star_map") + print(" ! Access path for f: result.fit_result.f_map") + print(" ! Access path for mask: result.fit_result.quality_mask") + print(" ! Not a dict – each parameter is an individual attribute!") + + return { + "class": type(result).__name__, + "parameter_maps_keys": ["d_map", "d_star_map", "f_map"], + "quality_mask_shape": list(qm_arr.shape) if qm_arr is not None else None, + "access_path_params": "result.fit_result.", + "access_path_mask": "result.fit_result.quality_mask", + "mask_guaranteed": True, + } + + +def _snapshot_runner() -> SnapshotDict: + """Show what run_analysis() currently returns for each modality.""" + _banner("runner.run_analysis() – Unified PipelineResult wrapper") + + from osipy.pipeline.runner import PipelineResult, run_analysis + + print(f"\n PipelineResult fields:") + import dataclasses + for f in dataclasses.fields(PipelineResult): + print(f"{INDENT * 2}.{f.name}: {f.type}") + + print(f"\n [OK] run_analysis() already maps each modality -> PipelineResult") + print(f" [OK] parameter_maps is always a dict[str, ParameterMap]") + print(f" [OK] quality_mask is always a numpy bool array") + print(f" [X] No provenance/version/timestamp in PipelineResult.metadata") + print(f" [X] PipelineResult is internal – not the proposed AnalysisResult") + + return { + "class": "PipelineResult", + "has_parameter_maps_dict": True, + "has_quality_mask": True, + "has_provenance": False, + "note": "run_analysis() wraps each pipeline, but lacks provenance.", + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Main +# ────────────────────────────────────────────────────────────────────────────── + +def main(save: bool = False) -> None: + from osipy._version import __version__ + + print("\n" + "=" * 72) + print(" osipy BEFORE-REFACTOR PIPELINE STATE SNAPSHOT") + print("=" * 72) + print(f" osipy version : {__version__}") + print(f" Python : {sys.version.split()[0]}") + print(f" Captured at : {datetime.datetime.now().isoformat(timespec='seconds')}") + print(f" Spatial shape : {SHAPE_3D} (synthetic – no real MRI data needed)") + + snapshot: dict[str, Any] = { + "osipy_version": __version__, + "captured_at": datetime.datetime.now().isoformat(timespec="seconds"), + "synthetic_shape": list(SHAPE_3D), + "modalities": {}, + } + + try: + snapshot["modalities"]["DCE"] = _snapshot_dce() + except Exception as exc: + print(f"\n [DCE] ERROR: {exc}") + snapshot["modalities"]["DCE"] = {"error": str(exc)} + + try: + snapshot["modalities"]["DSC"] = _snapshot_dsc() + except Exception as exc: + print(f"\n [DSC] ERROR: {exc}") + snapshot["modalities"]["DSC"] = {"error": str(exc)} + + try: + snapshot["modalities"]["ASL"] = _snapshot_asl() + except Exception as exc: + print(f"\n [ASL] ERROR: {exc}") + snapshot["modalities"]["ASL"] = {"error": str(exc)} + + try: + snapshot["modalities"]["IVIM"] = _snapshot_ivim() + except Exception as exc: + print(f"\n [IVIM] ERROR: {exc}") + snapshot["modalities"]["IVIM"] = {"error": str(exc)} + + try: + snapshot["runner"] = _snapshot_runner() + except Exception as exc: + print(f"\n [runner] ERROR: {exc}") + snapshot["runner"] = {"error": str(exc)} + + # ── Summary table ──────────────────────────────────────────────────────── + _banner("SUMMARY: The Fragmentation Problem (Before Refactor)") + print( + f" {'Modality':<8} {'Result Class':<26} {'Maps interface':<30} " + f"{'Mask Guaranteed'}" + ) + print(f" {'-'*8} {'-'*26} {'-'*30} {'-'*15}") + rows = [ + ("DCE", "DCEPipelineResult", "result.fit_result.parameter_maps[name]", "Yes"), + ("DSC", "DSCPipelineResult", "result.perfusion_maps.", "NO !"), + ("ASL", "ASLPipelineResult", "result.cbf_result.cbf_map", "Yes"), + ("IVIM", "IVIMPipelineResult", "result.fit_result._map", "Yes"), + ] + for m, cls, path, mask in rows: + print(f" {m:<8} {cls:<26} {path:<30} {mask}") + + print(f"\n -> After the AnalysisResult refactor, ALL four pipelines will expose:") + print(f" result.parameter_maps[name] (dict, always present)") + print(f" result.quality_mask (bool ndarray, never None)") + print(f" result.provenance (version + timestamp + config)") + + # ── Save ──────────────────────────────────────────────────────────────── + if save: + out_path = Path(__file__).parent / "state_before_refactor.json" + with open(out_path, "w") as fh: + json.dump(snapshot, fh, indent=2) + print(f"\n [OK] Snapshot saved -> {out_path}") + + print(f"\n{SEP}\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Capture osipy pipeline result structures BEFORE the AnalysisResult refactor." + ) + parser.add_argument( + "--save", + action="store_true", + help="Save snapshot as state_before_refactor.json next to this script.", + ) + args = parser.parse_args() + main(save=args.save) diff --git a/osipy/scripts/state_before_refactor.json b/osipy/scripts/state_before_refactor.json new file mode 100644 index 0000000..13f828a --- /dev/null +++ b/osipy/scripts/state_before_refactor.json @@ -0,0 +1,82 @@ +{ + "osipy_version": "0.1.1", + "captured_at": "2026-03-09T18:35:25", + "synthetic_shape": [ + 4, + 4, + 2 + ], + "modalities": { + "DCE": { + "class": "DCEPipelineResult", + "parameter_maps_keys": [ + "Ktrans", + "ve", + "r_squared" + ], + "quality_mask_shape": [ + 4, + 4, + 2 + ], + "model": "Standard Tofts", + "access_path_params": "result.fit_result.parameter_maps[name]", + "access_path_mask": "result.fit_result.quality_mask", + "mask_guaranteed": true + }, + "DSC": { + "class": "DSCPipelineResult", + "parameter_maps_keys": [ + "cbv", + "cbf", + "mtt", + "ttp" + ], + "quality_mask_shape": [ + 4, + 4, + 2 + ], + "access_path_params": "result.perfusion_maps. (individual attrs)", + "access_path_mask": "result.perfusion_maps.quality_mask", + "mask_guaranteed": false + }, + "ASL": { + "class": "ASLPipelineResult", + "parameter_maps_keys": [ + "cbf_map" + ], + "quality_mask_shape": [ + 4, + 4, + 2 + ], + "access_path_params": "result.cbf_result.cbf_map", + "access_path_mask": "result.cbf_result.quality_mask", + "mask_guaranteed": true + }, + "IVIM": { + "class": "IVIMPipelineResult", + "parameter_maps_keys": [ + "d_map", + "d_star_map", + "f_map" + ], + "quality_mask_shape": [ + 4, + 4, + 2 + ], + "access_path_params": "result.fit_result.", + "access_path_mask": "result.fit_result.quality_mask", + "mask_guaranteed": true + } + }, + "runner": { + "class": "PipelineResult", + "has_parameter_maps_dict": true, + "has_quality_mask": true, + "has_provenance": false, + "note": "run_analysis() wraps each pipeline, but lacks provenance." + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b684456..7b6703e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "OSIPI-compliant MRI perfusion analysis library" readme = "README.md" license = "MIT" -requires-python = ">=3.12" +requires-python = ">=3.10" authors = [ { name = "osipy contributors" }, ] diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index a378f71..2972e6a 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -6,9 +6,11 @@ from __future__ import annotations import numpy as np +import pytest -from osipy.common.types import Modality +from osipy.common.types import AnalysisResult, Modality from osipy.pipeline import ( + AnalysisResult, IVIMPipeline, IVIMPipelineConfig, PipelineResult, @@ -19,9 +21,8 @@ class TestRunAnalysis: """Tests for unified run_analysis function.""" - def test_run_analysis_returns_pipeline_result(self) -> None: - """Test that run_analysis returns PipelineResult.""" - # Create minimal IVIM data (simplest case) + def test_run_analysis_returns_analysis_result(self) -> None: + """Test that run_analysis returns AnalysisResult (the contract type).""" data = np.random.rand(8, 8, 4, 6) b_values = np.array([0, 50, 100, 200, 400, 800]) @@ -31,7 +32,7 @@ def test_run_analysis_returns_pipeline_result(self) -> None: b_values=b_values, ) - assert isinstance(result, PipelineResult) + assert isinstance(result, AnalysisResult) assert result.modality == Modality.IVIM def test_run_analysis_string_modality(self) -> None: @@ -47,28 +48,75 @@ def test_run_analysis_string_modality(self) -> None: assert result.modality == Modality.IVIM + def test_analysis_result_parameter_maps_is_dict(self) -> None: + """Test that parameter_maps is always a non-empty dict.""" + data = np.random.rand(4, 4, 2, 6) + b_values = np.array([0, 50, 100, 200, 400, 800]) + + result = run_analysis(data, modality="ivim", b_values=b_values) + + assert isinstance(result.parameter_maps, dict) + assert len(result.parameter_maps) > 0 + + def test_analysis_result_quality_mask_never_none(self) -> None: + """Test that quality_mask is always present and is a bool array.""" + data = np.random.rand(4, 4, 2, 6) + b_values = np.array([0, 50, 100, 200, 400, 800]) + + result = run_analysis(data, modality="ivim", b_values=b_values) + + assert result.quality_mask is not None + assert result.quality_mask.dtype == np.bool_ + + def test_analysis_result_provenance_fields(self) -> None: + """Test that provenance always contains the required audit fields.""" + data = np.random.rand(4, 4, 2, 6) + b_values = np.array([0, 50, 100, 200, 400, 800]) + + result = run_analysis(data, modality="ivim", b_values=b_values) + + assert "osipy_version" in result.provenance + assert "captured_at" in result.provenance + assert "modality" in result.provenance + assert "config" in result.provenance + assert result.provenance["modality"] == "ivim" + + def test_uniform_save_interface_all_modalities(self) -> None: + """AnalysisResult enables the same save code for every modality. + + This is the canonical test for the contract: downstream code that + only knows AnalysisResult should work identically for IVIM. + """ + data = np.random.rand(4, 4, 2, 6) + b_values = np.array([0, 50, 100, 200, 400, 800]) + result = run_analysis(data, modality="ivim", b_values=b_values) + + # This code works WITHOUT knowing the modality ─ that's the whole point + saved = {} + for name, pmap in result.parameter_maps.items(): + arr = pmap.values if hasattr(pmap, "values") else pmap + saved[name] = arr + assert result.quality_mask is not None + assert len(saved) > 0 + class TestIVIMPipelineIntegration: """Integration tests for IVIM pipeline.""" def test_ivim_pipeline_full_run(self) -> None: """Test full IVIM pipeline execution.""" - # Create synthetic IVIM data shape = (8, 8, 4) b_values = np.array([0, 50, 100, 200, 400, 800]) n_bvalues = len(b_values) - # Known parameters - d = 1.0e-3 # mm^2/s - d_star = 10e-3 # mm^2/s + d = 1.0e-3 + d_star = 10e-3 f = 0.1 - # Generate bi-exponential signal signal = np.zeros((*shape, n_bvalues)) for i, b in enumerate(b_values): signal[..., i] = f * np.exp(-b * d_star) + (1 - f) * np.exp(-b * d) - # Add noise signal += np.random.randn(*signal.shape) * 0.01 signal = np.maximum(signal, 0.01) @@ -76,6 +124,7 @@ def test_ivim_pipeline_full_run(self) -> None: pipeline = IVIMPipeline(config) result = pipeline.run(signal, b_values=b_values) + # pipeline.run() still returns IVIMPipelineResult (unchanged) assert result is not None assert hasattr(result, "fit_result") @@ -85,14 +134,12 @@ class TestPipelineMemoryEfficiency: def test_pipeline_handles_large_data(self) -> None: """Test pipeline can handle larger datasets without memory issues.""" - # Create moderately large data data = np.random.rand(32, 32, 8, 6).astype(np.float32) b_values = np.array([0, 50, 100, 200, 400, 800]) config = IVIMPipelineConfig() pipeline = IVIMPipeline(config) - # Should complete without memory error result = pipeline.run(data, b_values=b_values) assert result is not None @@ -104,12 +151,10 @@ def test_headless_execution_no_display(self) -> None: """Test pipeline runs without display requirements.""" import os - # Ensure no display is required original_display = os.environ.get("DISPLAY") try: os.environ.pop("DISPLAY", None) - # Run minimal analysis data = np.random.rand(4, 4, 2, 4) b_values = np.array([0, 100, 400, 800]) @@ -126,17 +171,16 @@ def test_pipeline_deterministic_results(self) -> None: data = np.random.rand(4, 4, 2, 4) b_values = np.array([0, 100, 400, 800]) - # Run twice with same seed np.random.seed(42) result1 = run_analysis(data.copy(), modality="ivim", b_values=b_values) np.random.seed(42) result2 = run_analysis(data.copy(), modality="ivim", b_values=b_values) - # Results should be identical for key in result1.parameter_maps: if key in result2.parameter_maps: - np.testing.assert_array_almost_equal( - result1.parameter_maps[key].values, - result2.parameter_maps[key].values, - ) + pm1 = result1.parameter_maps[key] + pm2 = result2.parameter_maps[key] + arr1 = pm1.values if hasattr(pm1, "values") else pm1 + arr2 = pm2.values if hasattr(pm2, "values") else pm2 + np.testing.assert_array_almost_equal(arr1, arr2) From 2a3f0678e43d4c21c5ae3ca5f338458d7713cd3b Mon Sep 17 00:00:00 2001 From: Akash Date: Mon, 9 Mar 2026 23:42:46 +0530 Subject: [PATCH 2/2] added unified AnalysisResult for all the outputs --- osipy/cli/runner.py | 4 ++-- osipy/common/io/bids.py | 4 ++-- osipy/common/logging.py | 4 ++-- osipy/common/validation/report.py | 4 ++-- osipy/dce/models/base.py | 7 ++----- osipy/pipeline/runner.py | 2 +- pyproject.toml | 4 ++-- 7 files changed, 13 insertions(+), 16 deletions(-) diff --git a/osipy/cli/runner.py b/osipy/cli/runner.py index c43579f..5f3fa06 100644 --- a/osipy/cli/runner.py +++ b/osipy/cli/runner.py @@ -9,7 +9,7 @@ import json import logging import time -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any @@ -1005,7 +1005,7 @@ def _save_metadata( metadata: dict[str, Any] = { "osipy_version": __version__, "modality": config.modality, - "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "timestamp": datetime.now(tz=UTC).isoformat(), "data_path": str(data_path), "config": config.model_dump(), } diff --git a/osipy/common/io/bids.py b/osipy/common/io/bids.py index 0651187..996e850 100644 --- a/osipy/common/io/bids.py +++ b/osipy/common/io/bids.py @@ -13,7 +13,7 @@ import csv import json import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -160,7 +160,7 @@ def export_bids( "Name": "osipy", "Version": __version__, }, - "Timestamp": datetime.now(timezone.utc).isoformat(), + "Timestamp": datetime.now(UTC).isoformat(), "Parameters": {}, } diff --git a/osipy/common/logging.py b/osipy/common/logging.py index 59a71dc..0f5c82c 100644 --- a/osipy/common/logging.py +++ b/osipy/common/logging.py @@ -8,7 +8,7 @@ import json import logging import sys -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import TextIO @@ -34,7 +34,7 @@ def format(self, record: logging.LogRecord) -> str: JSON-formatted log entry. """ log_entry = { - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "level": record.levelname, "logger": record.name, "module": record.module, diff --git a/osipy/common/validation/report.py b/osipy/common/validation/report.py index 9e80bfd..3830e19 100644 --- a/osipy/common/validation/report.py +++ b/osipy/common/validation/report.py @@ -12,7 +12,7 @@ import json as _json from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any @@ -186,7 +186,7 @@ def to_dict(self) -> dict[str, Any]: "tolerances": { k: v for k, v in self.tolerances.items() if k in self.parameters }, - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "version": getattr(osipy, "__version__", "unknown"), } diff --git a/osipy/dce/models/base.py b/osipy/dce/models/base.py index 0096f85..2a5bf5a 100644 --- a/osipy/dce/models/base.py +++ b/osipy/dce/models/base.py @@ -12,7 +12,7 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any from osipy.common.backend.array_module import get_array_module, to_numpy from osipy.common.models.base import BaseSignalModel @@ -29,10 +29,7 @@ class ModelParameters: pass -P = TypeVar("P", bound=ModelParameters) - - -class BasePerfusionModel(BaseSignalModel, Generic[P]): +class BasePerfusionModel[P: ModelParameters](BaseSignalModel): """Abstract base class for perfusion pharmacokinetic models. All DCE/DSC models must inherit from this class and implement diff --git a/osipy/pipeline/runner.py b/osipy/pipeline/runner.py index c002b95..2df4201 100644 --- a/osipy/pipeline/runner.py +++ b/osipy/pipeline/runner.py @@ -86,7 +86,7 @@ def _make_provenance( prov: dict[str, Any] = { "osipy_version": __version__, - "captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat( + "captured_at": datetime.datetime.now(datetime.UTC).isoformat( timespec="seconds" ), "modality": modality.value, diff --git a/pyproject.toml b/pyproject.toml index 7b6703e..ef218d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "OSIPI-compliant MRI perfusion analysis library" readme = "README.md" license = "MIT" -requires-python = ">=3.10" +requires-python = ">=3.12" authors = [ { name = "osipy contributors" }, ] @@ -27,7 +27,7 @@ classifiers = [ "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Medical Science Apps.",