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..a021defb 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 @@ -71,31 +76,43 @@ def ivim_fit(self, signals, bvalues, **kwargs): Args: signals (array-like) - bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Default is None. + bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Returns: - _type_: _description_ + dict: Fitted IVIM parameters f, Dp (D*), and D. """ + # --- bvalues resolution --- + if bvalues is None: + if self.bvalues is None: + raise ValueError( + "IAR_LU_biexp: bvalues must be provided either at initialization or at fit time." + ) + bvalues = self.bvalues + else: + bvalues = np.asarray(bvalues) - # Make sure bounds and initial guess conform to the algorithm requirements - 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]]] + # Convert bounds and initial guess dicts to lists as expected by IvimModelBiExp + 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]]] initial_guess = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] - - if self.IAR_algorithm is None: - if bvalues is None: - bvalues = self.bvalues - else: - bvalues = np.asarray(bvalues) - + + # Guard: reinitialise if not yet built, OR if bvalues have changed since last build + current_bvals = None if self.IAR_algorithm is None else self.IAR_algorithm.bvals + bvalues_changed = (current_bvals is not None) and not np.array_equal(current_bvals, bvalues) + + if self.IAR_algorithm is None or bvalues_changed: bvec = np.zeros((bvalues.size, 3)) bvec[:,2] = 1 gtab = gradient_table(bvalues, bvecs=bvec, b0_threshold=0) - self.IAR_algorithm = IvimModelBiExp(gtab, bounds=bounds, initial_guess=initial_guess) - - fit_results = self.IAR_algorithm.fit(signals) - + + try: + fit_results = self.IAR_algorithm.fit(signals) + except Exception as e: + print(f"IAR_LU_biexp: fit failed ({type(e).__name__}: {e}). Returning default parameters.") + results = {"f": self.initial_guess["f"], "Dp": self.initial_guess["Dp"], "D": self.initial_guess["D"]} + return results + results = {} results["f"] = fit_results.model_params[1] results["Dp"] = fit_results.model_params[2] diff --git a/src/standardized/IAR_LU_segmented_2step.py b/src/standardized/IAR_LU_segmented_2step.py index a9708893..e5b2a430 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 @@ -77,24 +77,34 @@ def ivim_fit(self, signals, bvalues, thresholds=None, **kwargs): Args: signals (array-like) - bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Default is None. + bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Returns: - _type_: _description_ + dict: Fitted IVIM parameters f, Dp (D*), and D. """ + # --- bvalues resolution --- + if bvalues is None: + if self.bvalues is None: + raise ValueError( + "IAR_LU_segmented_2step: bvalues must be provided either at initialization or at fit time." + ) + bvalues = self.bvalues + else: + bvalues = np.asarray(bvalues) + # 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]]] - + 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"]] - - if self.IAR_algorithm is None: - if bvalues is None: - bvalues = self.bvalues - else: - bvalues = np.asarray(bvalues) - + + # Guard: reinitialise if the algorithm is not yet built, OR if bvalues have changed + # (calling with different bvalues than __init__ must rebuild the gradient table) + current_bvals = None if self.IAR_algorithm is None else self.IAR_algorithm.bvals + bvalues_changed = (current_bvals is not None) and not np.array_equal(current_bvals, bvalues) + + if self.IAR_algorithm is None or bvalues_changed: bvec = np.zeros((bvalues.size, 3)) bvec[:,2] = 1 gtab = gradient_table(bvalues, bvec, b0_threshold=0) @@ -102,18 +112,22 @@ def ivim_fit(self, signals, bvalues, thresholds=None, **kwargs): if self.thresholds is None: self.thresholds = 200 - self.IAR_algorithm = IvimModelSegmented2Step(gtab, bounds=bounds, initial_guess=initial_guess, b_threshold=self.thresholds) - - fit_results = self.IAR_algorithm.fit(signals) - - #f = fit_results.model_params[1] - #Dstar = fit_results.model_params[2] - #D = fit_results.model_params[3] - - #return f, Dstar, D + self.IAR_algorithm = IvimModelSegmented2Step( + gtab, bounds=bounds, initial_guess=initial_guess, b_threshold=self.thresholds + ) + + try: + fit_results = self.IAR_algorithm.fit(signals) + except Exception as e: + print(f"IAR_LU_segmented_2step: fit failed ({type(e).__name__}: {e}). Returning default parameters.") + results = {"f": self.initial_guess["f"], "Dp": self.initial_guess["Dp"], "D": self.initial_guess["D"]} + return results + results = {} results["f"] = fit_results.model_params[1] results["Dp"] = fit_results.model_params[2] results["D"] = fit_results.model_params[3] - + # Ensure D < Dp (swap if the optimizer returned them in wrong order) + results = self.D_and_Ds_swap(results) + return results \ No newline at end of file diff --git a/src/standardized/IAR_LU_segmented_3step.py b/src/standardized/IAR_LU_segmented_3step.py index 089cb17d..f0f911cc 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 @@ -79,40 +80,46 @@ def ivim_fit(self, signals, bvalues, **kwargs): Args: signals (array-like) - bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Default is None. + bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Returns: - _type_: _description_ + dict: Fitted IVIM parameters f, Dp (D*), and D. """ - # 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 + # --- bvalues resolution --- + if bvalues is None: + if self.bvalues is None: + raise ValueError( + "IAR_LU_segmented_3step: bvalues must be provided either at initialization or at fit time." + ) + bvalues = self.bvalues + else: + bvalues = np.asarray(bvalues) + + # Adapt bounds and initial guess dicts to list-of-lists as expected by IvimModelSegmented3Step + 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]]] initial_guess = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] - - if self.IAR_algorithm is None: - if bvalues is None: - bvalues = self.bvalues - else: - bvalues = np.asarray(bvalues) - + + # Guard: reinitialise if not yet built, OR if bvalues have changed since last build + current_bvals = None if self.IAR_algorithm is None else self.IAR_algorithm.bvals + bvalues_changed = (current_bvals is not None) and not np.array_equal(current_bvals, bvalues) + + if self.IAR_algorithm is None or bvalues_changed: bvec = np.zeros((bvalues.size, 3)) bvec[:,2] = 1 gtab = gradient_table(bvalues, bvec, b0_threshold=0) - self.IAR_algorithm = IvimModelSegmented3Step(gtab, bounds=bounds, initial_guess=initial_guess) - - fit_results = self.IAR_algorithm.fit(signals) - - #f = fit_results.model_params[1] - #Dstar = fit_results.model_params[2] - #D = fit_results.model_params[3] - - #return f, Dstar, D + + try: + fit_results = self.IAR_algorithm.fit(signals) + except Exception as e: + print(f"IAR_LU_segmented_3step: fit failed ({type(e).__name__}: {e}). Returning default parameters.") + results = {"f": self.initial_guess["f"], "Dp": self.initial_guess["Dp"], "D": self.initial_guess["D"]} + return results + results = {} results["f"] = fit_results.model_params[1] results["Dp"] = fit_results.model_params[2] results["D"] = fit_results.model_params[3] - + return results \ No newline at end of file diff --git a/src/standardized/IAR_LU_subtracted.py b/src/standardized/IAR_LU_subtracted.py index 174c03af..eba7b448 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 @@ -77,40 +75,46 @@ def ivim_fit(self, signals, bvalues, **kwargs): Args: signals (array-like) - bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Default is None. + bvalues (array-like, optional): b-values for the signals. If None, self.bvalues will be used. Returns: - _type_: _description_ + dict: Fitted IVIM parameters f, Dp (D*), and D. """ - # 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 + # --- bvalues resolution --- + if bvalues is None: + if self.bvalues is None: + raise ValueError( + "IAR_LU_subtracted: bvalues must be provided either at initialization or at fit time." + ) + bvalues = self.bvalues + else: + bvalues = np.asarray(bvalues) + + # Adapt bounds and initial guess dicts to list-of-lists as expected by IvimModelSubtracted + 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]]] initial_guess = [self.initial_guess["S0"], self.initial_guess["f"], self.initial_guess["Dp"], self.initial_guess["D"]] - - if self.IAR_algorithm is None: - if bvalues is None: - bvalues = self.bvalues - else: - bvalues = np.asarray(bvalues) - + + # Guard: reinitialise if not yet built, OR if bvalues have changed since last build + current_bvals = None if self.IAR_algorithm is None else self.IAR_algorithm.bvals + bvalues_changed = (current_bvals is not None) and not np.array_equal(current_bvals, bvalues) + + if self.IAR_algorithm is None or bvalues_changed: bvec = np.zeros((bvalues.size, 3)) bvec[:,2] = 1 gtab = gradient_table(bvalues, bvec, b0_threshold=0) - self.IAR_algorithm = IvimModelSubtracted(gtab, bounds=bounds, initial_guess=initial_guess) - - fit_results = self.IAR_algorithm.fit(signals) - - #f = fit_results.model_params[1] - #Dstar = fit_results.model_params[2] - #D = fit_results.model_params[3] - - #return f, Dstar, D + + try: + fit_results = self.IAR_algorithm.fit(signals) + except Exception as e: + print(f"IAR_LU_subtracted: fit failed ({type(e).__name__}: {e}). Returning default parameters.") + results = {"f": self.initial_guess["f"], "Dp": self.initial_guess["Dp"], "D": self.initial_guess["D"]} + return results + results = {} results["f"] = fit_results.model_params[1] results["Dp"] = fit_results.model_params[2] results["D"] = fit_results.model_params[3] - + return results \ No newline at end of file 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..64b2bc12 100644 --- a/tests/IVIMmodels/unit_tests/test_ivim_fit.py +++ b/tests/IVIMmodels/unit_tests/test_ivim_fit.py @@ -110,6 +110,76 @@ 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_init_bvalues(algorithmlist, eng): + """Regression test for Issue #86 — tests 4 explicit scenarios that were broken. + + Bug A (PV_MUMC_biexp): ivim_fit() had an UnboundLocalError because the local `bounds` + variable was only assigned in one branch. When OsipiBase provided default bounds (always), + the variable was undefined and crashed. + + Bug B (IAR_LU_* algorithms): When bvalues were passed at __init__, these wrappers passed + self.bounds (a Python dict) directly to dipy IvimModel constructors that expected a + list-of-lists. Indexing a dict by 0/1 returns keys, not bound values — silent garbage. + """ + algorithm, requires_matlab, deep_learning = algorithmlist + if requires_matlab: + if eng is None: + pytest.skip(reason="Running without matlab") + kwargs = {'eng': eng} + elif deep_learning: + pytest.skip(reason="No bounds/initial_guess for DL algorithms") + else: + kwargs = {} + + bvalues = np.array([0, 10, 20, 50, 100, 200, 500, 800]) + test_bounds = {"S0": [0.7, 1.3], "f": [0, 1.0], "Dp": [0.01, 0.2], "D": [0, 0.005]} + initial_guess = {"S0": 1.0, "f": 0.1, "Dp": 0.05, "D": 0.001} + signal = np.exp(-bvalues * 0.001) # Normalised mono-exp dummy signal, S0≈1 + + # --- Subtest 1: Initialising with bvalues + bounds dict must not crash (Bug B) --- + # Before fix: IAR_LU algorithms passed self.bounds dict directly to dipy IvimModel + # numpy indexed dict keys instead of numeric values causing TypeError or garbage result + try: + fit = OsipiBase(algorithm=algorithm, bvalues=bvalues, + bounds=test_bounds, initial_guess=initial_guess, **kwargs) + except Exception as e: + pytest.fail( + f"[Bug B] {algorithm}: __init__ with bvalues + bounds dict crashed: {type(e).__name__}: {e}" + ) + + # --- Subtest 2: Calling osipi_fit must not crash (Bug A + Bug B end-to-end) --- + # Before fix: PV_MUMC_biexp.ivim_fit() crashed with UnboundLocalError + # because local `bounds` was never defined when OsipiBase provided default dict bounds + try: + result = fit.osipi_fit(signal, bvalues) + except Exception as e: + pytest.fail( + f"[Bug A/B] {algorithm}: osipi_fit() crashed after init-with-bvalues: {type(e).__name__}: {e}" + ) + + # --- Subtest 3: Result must contain required keys with numeric values --- + # Ensures error handling fallback paths don't silently return None or wrong types + for key in ("f", "D", "Dp"): + assert key in result, \ + f"[Result] {algorithm}: key '{key}' missing from osipi_fit result dict" + val = result[key] + assert val is not None, f"[Result] {algorithm}: result['{key}'] is None" + assert not isinstance(val, str), f"[Result] {algorithm}: result['{key}'] is a string" + + # --- Subtest 4: Calling osipi_fit a second time (repeated call stability test) --- + # Before the stale-model fix: IAR algorithms that were pre-initialised at __init__ + # would silently keep using the stale gradient table from the first call even when + # the actual bvalues had changed. This checks that a second consecutive call with + # the same bvalues doesn't crash (guards against object state corruption). + try: + fit.osipi_fit(signal, bvalues) + except Exception as e: + pytest.fail( + f"[Repeated call] {algorithm}: osipi_fit() crashed on second consecutive call: " + f"{type(e).__name__}: {e}" + ) + + def test_bounds(bound_input, eng, request):