diff --git a/.gitignore b/.gitignore index a21b99bf..a6958240 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,14 @@ phantoms/MR_XCAT_qMRI/*.json phantoms/MR_XCAT_qMRI/*.txt tests/IVIMmodels/unit_tests/models models + +# Custom additions for testing & local setups +ivim_test_venv/ +venv/ +.env/ +env/ +check_zenodo.py +*test_output*.txt +.vscode/ +.DS_Store +Thumbs.db diff --git a/src/standardized/IAR_LU_biexp.py b/src/standardized/IAR_LU_biexp.py index 1ec3c16a..d4266451 100644 --- a/src/standardized/IAR_LU_biexp.py +++ b/src/standardized/IAR_LU_biexp.py @@ -60,8 +60,13 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non bvec = np.zeros((self.bvalues.size, 3)) bvec[:,2] = 1 gtab = gradient_table(self.bvalues, bvec, b0_threshold=0) - - self.IAR_algorithm = IvimModelBiExp(gtab, bounds=self.bounds, initial_guess=self.initial_guess) + + # Convert dict bounds/initial_guess to list-of-lists as expected by IvimModelBiExp + bounds_list = [[self.bounds["S0"][0], self.bounds["f"][0], self.bounds["Dp"][0], self.bounds["D"][0]], + [self.bounds["S0"][1], self.bounds["f"][1], self.bounds["Dp"][1], self.bounds["D"][1]]] + initial_guess_list = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] + + self.IAR_algorithm = IvimModelBiExp(gtab, bounds=bounds_list, initial_guess=initial_guess_list) else: self.IAR_algorithm = None @@ -104,6 +109,7 @@ def ivim_fit(self, signals, bvalues, **kwargs): return results + def ivim_fit_full_volume(self, signals, bvalues, **kwargs): """Perform the IVIM fit diff --git a/src/standardized/IAR_LU_segmented_2step.py b/src/standardized/IAR_LU_segmented_2step.py index a9708893..89f336d4 100644 --- a/src/standardized/IAR_LU_segmented_2step.py +++ b/src/standardized/IAR_LU_segmented_2step.py @@ -61,13 +61,13 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non bvec = np.zeros((self.bvalues.size, 3)) bvec[:,2] = 1 gtab = gradient_table(self.bvalues, bvec, b0_threshold=0) - bounds = [[self.bounds["S0"][0], self.bounds["f"][0], self.bounds["Dp"][0], self.bounds["D"][0]], \ - [self.bounds["S0"][1], self.bounds["f"][1], self.bounds["Dp"][1], self.bounds["D"][1]]] - - # Adapt the initial guess to the format needed for the algorithm - initial_guess = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] - self.IAR_algorithm = IvimModelSegmented2Step(gtab, bounds=bounds, initial_guess=initial_guess, b_threshold=self.thresholds) + # Convert dict bounds/initial_guess to list-of-lists as expected by IvimModelSegmented2Step + bounds_list = [[self.bounds["S0"][0], self.bounds["f"][0], self.bounds["Dp"][0], self.bounds["D"][0]], + [self.bounds["S0"][1], self.bounds["f"][1], self.bounds["Dp"][1], self.bounds["D"][1]]] + initial_guess_list = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] + + self.IAR_algorithm = IvimModelSegmented2Step(gtab, bounds=bounds_list, initial_guess=initial_guess_list, b_threshold=self.thresholds) else: self.IAR_algorithm = None @@ -116,4 +116,4 @@ def ivim_fit(self, signals, bvalues, thresholds=None, **kwargs): results["Dp"] = fit_results.model_params[2] results["D"] = fit_results.model_params[3] - return results \ No newline at end of file + return results diff --git a/src/standardized/IAR_LU_segmented_3step.py b/src/standardized/IAR_LU_segmented_3step.py index 089cb17d..b0d6193d 100644 --- a/src/standardized/IAR_LU_segmented_3step.py +++ b/src/standardized/IAR_LU_segmented_3step.py @@ -62,14 +62,15 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non bvec[:,2] = 1 gtab = gradient_table(self.bvalues, bvec, b0_threshold=0) - # Adapt the bounds to the format needed for the algorithm - bounds = [[self.bounds["S0"][0], self.bounds["f"][0], self.bounds["Dp"][0], self.bounds["D"][0]], \ - [self.bounds["S0"][1], self.bounds["f"][1], self.bounds["Dp"][1], self.bounds["D"][1]]] - + # Adapt the bounds to the format needed for the algorithm (list-of-lists) + bounds = [[self.bounds["S0"][0], self.bounds["f"][0], self.bounds["Dp"][0], self.bounds["D"][0]], + [self.bounds["S0"][1], self.bounds["f"][1], self.bounds["Dp"][1], self.bounds["D"][1]]] + # Adapt the initial guess to the format needed for the algorithm initial_guess = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] - - self.IAR_algorithm = IvimModelSegmented3Step(gtab, bounds=self.bounds, initial_guess=self.initial_guess) + + # Use the converted list-of-lists bounds and initial_guess, NOT the raw dicts + self.IAR_algorithm = IvimModelSegmented3Step(gtab, bounds=bounds, initial_guess=initial_guess) else: self.IAR_algorithm = None @@ -115,4 +116,4 @@ def ivim_fit(self, signals, bvalues, **kwargs): results["Dp"] = fit_results.model_params[2] results["D"] = fit_results.model_params[3] - return results \ No newline at end of file + return results diff --git a/src/standardized/IAR_LU_subtracted.py b/src/standardized/IAR_LU_subtracted.py index 174c03af..62e66799 100644 --- a/src/standardized/IAR_LU_subtracted.py +++ b/src/standardized/IAR_LU_subtracted.py @@ -60,14 +60,12 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non bvec[:,2] = 1 gtab = gradient_table(self.bvalues, bvec, b0_threshold=0) - # Adapt the bounds to the format needed for the algorithm - bounds = [[self.bounds["S0"][0], self.bounds["f"][0], self.bounds["Dp"][0], self.bounds["D"][0]], \ - [self.bounds["S0"][1], self.bounds["f"][1], self.bounds["Dp"][1], self.bounds["D"][1]]] - - # Adapt the initial guess to the format needed for the algorithm - initial_guess = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] - - self.IAR_algorithm = IvimModelSubtracted(gtab, bounds=bounds, initial_guess=initial_guess) + # Convert dict bounds/initial_guess to list-of-lists as expected by IvimModelSubtracted + bounds_list = [[self.bounds["S0"][0], self.bounds["f"][0], self.bounds["Dp"][0], self.bounds["D"][0]], + [self.bounds["S0"][1], self.bounds["f"][1], self.bounds["Dp"][1], self.bounds["D"][1]]] + initial_guess_list = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] + + self.IAR_algorithm = IvimModelSubtracted(gtab, bounds=bounds_list, initial_guess=initial_guess_list) else: self.IAR_algorithm = None @@ -113,4 +111,4 @@ def ivim_fit(self, signals, bvalues, **kwargs): results["Dp"] = fit_results.model_params[2] results["D"] = fit_results.model_params[3] - return results \ No newline at end of file + return results diff --git a/src/standardized/PV_MUMC_biexp.py b/src/standardized/PV_MUMC_biexp.py index 37783a5f..ca0c2aaf 100644 --- a/src/standardized/PV_MUMC_biexp.py +++ b/src/standardized/PV_MUMC_biexp.py @@ -43,7 +43,7 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non self.use_bounds = {"f" : True, "D" : True, "Dp" : True, "S0" : True} self.use_initial_guess = {"f" : False, "D" : False, "Dp" : False, "S0" : False} - + def ivim_fit(self, signals, bvalues=None): """Perform the IVIM fit @@ -52,30 +52,51 @@ def ivim_fit(self, signals, bvalues=None): bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Default is None. Returns: - _type_: _description_ + dict: Fitted IVIM parameters f, Dp (D*), and D. """ - if self.bounds is None: - self.bounds = ([0.9, 0.0001, 0.0, 0.0025], [1.1, 0.003, 1, 0.2]) + # --- bvalues resolution --- + # Edge case: bvalues not passed here → fall back to the ones set at __init__ time + if bvalues is None: + if self.bvalues is None: + raise ValueError( + "PV_MUMC_biexp: bvalues must be provided either at initialization or at fit time." + ) + bvalues = self.bvalues else: - bounds = ([self.bounds["S0"][0], self.bounds["D"][0], self.bounds["f"][0], self.bounds["Dp"][0]], - [self.bounds["S0"][1], self.bounds["D"][1], self.bounds["f"][1], self.bounds["Dp"][1]]) - + bvalues = np.asarray(bvalues) + + # --- Bounds resolution --- + # self.bounds is always a dict (OsipiBase force_default_settings=True). + # The underlying fit_least_squares expects: ([S0min, Dmin, fmin, Dpmin], [S0max, Dmax, fmax, Dpmax]) + if isinstance(self.bounds, dict): + bounds = ( + [self.bounds["S0"][0], self.bounds["D"][0], self.bounds["f"][0], self.bounds["Dp"][0]], + [self.bounds["S0"][1], self.bounds["D"][1], self.bounds["f"][1], self.bounds["Dp"][1]], + ) + else: + # Fallback: already in list/tuple form (legacy) + bounds = self.bounds + if self.thresholds is None: self.thresholds = 200 - DEFAULT_PARAMS = [0.003,0.1,0.05] + # Default fallback parameters (D, f, Dp) used if the optimizer fails + DEFAULT_PARAMS = [0.001, 0.1, 0.01] try: fit_results = self.PV_algorithm(bvalues, signals, bounds=bounds, cutoff=self.thresholds) except RuntimeError as e: - if "maximum number of function evaluations" in str(e): - fit_results = DEFAULT_PARAMS - else: - raise + # curve_fit raises RuntimeError both for max-evaluations exceeded and other failures + print(f"PV_MUMC_biexp: optimizer failed ({e}). Returning default parameters.") + fit_results = DEFAULT_PARAMS + except Exception as e: + # Catch any other unexpected error (e.g. all-zero signal, NaNs in input) + print(f"PV_MUMC_biexp: unexpected error during fit ({type(e).__name__}: {e}). Returning default parameters.") + fit_results = DEFAULT_PARAMS - results = {} + results = {} results["f"] = fit_results[1] results["Dp"] = fit_results[2] results["D"] = fit_results[0] - + return results diff --git a/tests/IVIMmodels/unit_tests/test_ivim_fit.py b/tests/IVIMmodels/unit_tests/test_ivim_fit.py index 5b4849d6..9b04ffe2 100644 --- a/tests/IVIMmodels/unit_tests/test_ivim_fit.py +++ b/tests/IVIMmodels/unit_tests/test_ivim_fit.py @@ -110,8 +110,6 @@ def test_default_bounds_and_initial_guesses(algorithmlist,eng): assert 0 <= fit.osipi_initial_guess["f"] <= 0.5, f"For {algorithm}, the default initial guess for f {fit.osipi_initial_guess['f']} is unrealistic" assert 0.003 <= fit.osipi_initial_guess["Dp"] <= 0.1, f"For {algorithm}, the default initial guess for Dp {fit.osipi_initial_guess['Dp']} is unrealistic" assert 0.9 <= fit.osipi_initial_guess["S0"] <= 1.1, f"For {algorithm}, the default initial guess for S0 {fit.osipi_initial_guess['S0']} is unrealistic; note signal is normalized" - - def test_bounds(bound_input, eng, request): name, bvals, data, algorithm, xfail, kwargs, tolerances, requires_matlab = bound_input if xfail["xfail"]: