diff --git a/CHANGELOG b/CHANGELOG index fda13cc..55e6af5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ 0.16.4 + - fix: ensure identical results when clicking per file and compute button (#47, #48) - setup: support latest python versions (#32, #46) - setup: support latest matplotlib versions (#32, #46) - fix: remember user's choice upon curve open (#44, #45) diff --git a/pyjibe/fd/main.py b/pyjibe/fd/main.py index 35c27b9..cf6d97a 100644 --- a/pyjibe/fd/main.py +++ b/pyjibe/fd/main.py @@ -83,6 +83,7 @@ def __init__(self, *args, **kwargs): self.on_cb_rating_scheme) self.btn_rater.clicked.connect(self.on_user_rate) # plotting parameters + self.cb_show_fit_line.stateChanged.connect(self.on_mpl_curve_update) self.cb_mpl_rescale_plot_x.stateChanged.connect( self.on_mpl_curve_update) self.cb_mpl_rescale_plot_x_min.valueChanged.connect( @@ -512,6 +513,8 @@ def on_fit_all(self): try: # preprocessing could fail for bad data self.tab_preprocess.apply_preprocessing(fdist) + # update model key and ancillary parameters before fitting + self.tab_fit.fit_update_parameters(fdist) # external fitting model could fail self.tab_fit.fit_approach_retract(fdist, update_ui=False) except BaseException as e: diff --git a/pyjibe/fd/main.ui b/pyjibe/fd/main.ui index 0bddabc..bcc710b 100644 --- a/pyjibe/fd/main.ui +++ b/pyjibe/fd/main.ui @@ -138,6 +138,16 @@ + + + + Show fit line + + + true + + + diff --git a/pyjibe/fd/mpl_indent.py b/pyjibe/fd/mpl_indent.py index 5714f4e..93a3a81 100644 --- a/pyjibe/fd/mpl_indent.py +++ b/pyjibe/fd/mpl_indent.py @@ -50,6 +50,11 @@ def __init__(self): range(2), label="residuals")[0] + self.ann_r2 = self.axis_main.text( + 0.98, 0.05, "", transform=self.axis_main.transAxes, + ha="right", va="bottom", fontsize=9, + bbox=dict(boxstyle="round,pad=0.2", fc="white", alpha=0.7)) + self.canvas = FigureCanvas(self.figure) self.canvas.draw() @@ -67,7 +72,7 @@ def add_toolbar(self, widget): def save_data_callback(self, filename): self.fdist.export(filename) - def update(self, fdist, rescale_x=None, rescale_y=None): + def update(self, fdist, rescale_x=None, rescale_y=None, show_fit=True): self.fdist = fdist xaxis = "tip position" yaxis = "force" @@ -96,13 +101,22 @@ def update(self, fdist, rescale_x=None, rescale_y=None): if "fit" in fdist and np.sum(fdist["fit range"]): self.plots["residuals"].set_visible(True) - self.plots["fit"].set_visible(True) + self.plots["fit"].set_visible(show_fit) self.plots["fit range"].set_visible(True) self.plots["fit"].set_data(fdist["tip position"]*xscale, fdist["fit"]*yscale) self.plots["residuals"].set_data(fdist["tip position"]*xscale, (fdist["fit residuals"])*yscale) + + fit_mask = fdist["fit range"] + y_true = fdist["force"][fit_mask] + y_pred = fdist["fit"][fit_mask] + ss_res = np.sum((y_true - y_pred) ** 2) + ss_tot = np.sum((y_true - np.mean(y_true)) ** 2) + r2 = 1 - ss_res / ss_tot if ss_tot != 0 else np.nan + self.ann_r2.set_text(f"R² = {r2:.4f}") + self.ann_r2.set_visible(True) # fit range fitrange = (fdist[xaxis]*xscale)[fdist["fit range"]] fitmin = np.min(fitrange) @@ -132,6 +146,8 @@ def update(self, fdist, rescale_x=None, rescale_y=None): self.plots["residuals"].set_visible(False) self.plots["fit"].set_visible(False) self.plots["fit range"].set_visible(False) + self.ann_r2.set_text("") + self.ann_r2.set_visible(False) self.canvas.draw() def update_plot(self, rescale_x=None, rescale_y=None): diff --git a/pyjibe/fd/widget_plot_fd.py b/pyjibe/fd/widget_plot_fd.py index 65f4105..0eeab9b 100644 --- a/pyjibe/fd/widget_plot_fd.py +++ b/pyjibe/fd/widget_plot_fd.py @@ -36,6 +36,8 @@ def mpl_curve_update(self, fdist): rescale_y = (self.fd.cb_mpl_rescale_plot_y_min.value(), self.fd.cb_mpl_rescale_plot_y_max.value()) + show_fit = self.fd.cb_show_fit_line.isChecked() self.mpl_curve.update(fdist, rescale_x=rescale_x, - rescale_y=rescale_y) + rescale_y=rescale_y, + show_fit=show_fit) diff --git a/tests/test_fd_fit.py b/tests/test_fd_fit.py index 5f37733..f2529aa 100644 --- a/tests/test_fd_fit.py +++ b/tests/test_fd_fit.py @@ -262,3 +262,121 @@ def test_set_indentation_depth_manually_infdoublespinbox(qtbot): qtbot.keyClicks(war.tab_fit.sp_range_1, text_entered) assert war.tab_fit.sp_range_1.value() == resulting_value main_window.close() + + +def test_fit_all_matches_single_fit(qtbot): + """on_fit_all must produce the same fitted params as fitting one-by-one. + + Regression test for issue #47: fit-all skipped fit_update_parameters(), + so ancillary parameters were not recomputed, leading to different TSV + output compared to the single-curve path. + """ + main_window = pyjibe.head.PyJibe() + qtbot.addWidget(main_window) + main_window.load_data(files=make_directory_with_data(2)) + war = main_window.subwindows[0].widget() + war.cb_autosave.setChecked(0) + war.tab_preprocess.set_preprocessing(["compute_tip_position"]) + war.tab_fit.cb_weight_cp.setCheckState(QtCore.Qt.CheckState.Unchecked) + + # Fit first curve via the single-curve path (on_curve_list triggers this) + war.on_tab_changed() + fdist0 = war.data_set[0] + assert fdist0.fit_properties.get("success", False), ( + "single fit must succeed") + single_E = fdist0.fit_properties["params_fitted"]["E"].value + + # Now re-fit everything via fit-all + war.on_fit_all() + fitall_E = fdist0.fit_properties["params_fitted"]["E"].value + + assert single_E == pytest.approx(fitall_E, rel=1e-6), ( + f"fit-all gave E={fitall_E:.4g} but single-fit gave E={single_E:.4g}") + main_window.close() + + +def test_fit_all_matches_single_fit_kvm(qtbot): + """fit-all must match single-fit for the KVM model (issue #47). + + KVM has ancillary parameters (eta, time_ind) that are derived by fitting + a preliminary Hertz model to each curve. Before the fix, on_fit_all() + skipped fit_update_parameters(), so those ancillaries were never + recomputed and the fitted Eu values differed from the single-curve path. + + The KVM model is loaded via PyJibe's extension system at startup. + """ + main_window = pyjibe.head.PyJibe() + qtbot.addWidget(main_window) + main_window.load_data(files=make_directory_with_data(2)) + war = main_window.subwindows[0].widget() + war.cb_autosave.setChecked(0) + war.tab_preprocess.set_preprocessing(["compute_tip_position"]) + war.tab_fit.cb_weight_cp.setCheckState(QtCore.Qt.CheckState.Unchecked) + + # Switch to the KVM model (loaded via PyJibe's extension system) + idx = war.tab_fit.cb_model.findData("hertz_corr_visco_KVM") + if idx < 0: + pytest.skip("KVM model extension not installed in this environment") + war.tab_fit.cb_model.setCurrentIndex(idx) + + # Fit first curve via the single-curve path + war.on_tab_changed() + fdist0 = war.data_set[0] + assert fdist0.fit_properties.get("success", False), "single KVM fit failed" + single_Eu = fdist0.fit_properties["params_fitted"]["Eu"].value + + # Re-fit everything via fit-all + war.on_fit_all() + fitall_Eu = fdist0.fit_properties["params_fitted"]["Eu"].value + + assert single_Eu == pytest.approx(fitall_Eu, rel=1e-6), ( + f"fit-all gave Eu={fitall_Eu:.4g} but single-fit " + f"gave Eu={single_Eu:.4g}") + main_window.close() + + +def test_show_fit_line_toggle(qtbot): + """Toggle cb_show_fit_line hides/shows the fit line on the plot.""" + main_window = pyjibe.head.PyJibe() + qtbot.addWidget(main_window) + main_window.load_data(files=make_directory_with_data(1)) + war = main_window.subwindows[0].widget() + war.cb_autosave.setChecked(0) + war.tab_preprocess.set_preprocessing(["compute_tip_position"]) + war.tab_fit.cb_weight_cp.setCheckState(QtCore.Qt.CheckState.Unchecked) + war.on_tab_changed() + + mpl = war.widget_plot_fd.mpl_curve + # fit line should be visible by default after fitting + assert mpl.plots["fit"].get_visible() + + # uncheck the toggle — fit line should disappear + war.cb_show_fit_line.setChecked(False) + war.on_mpl_curve_update() + assert not mpl.plots["fit"].get_visible() + + # re-check — fit line should reappear + war.cb_show_fit_line.setChecked(True) + war.on_mpl_curve_update() + assert mpl.plots["fit"].get_visible() + main_window.close() + + +def test_r2_annotation_shown_after_fit(qtbot): + """R² annotation is visible and well-formed after a successful fit.""" + main_window = pyjibe.head.PyJibe() + qtbot.addWidget(main_window) + main_window.load_data(files=make_directory_with_data(1)) + war = main_window.subwindows[0].widget() + war.cb_autosave.setChecked(0) + war.tab_preprocess.set_preprocessing(["compute_tip_position"]) + war.tab_fit.cb_weight_cp.setCheckState(QtCore.Qt.CheckState.Unchecked) + war.on_tab_changed() + + mpl = war.widget_plot_fd.mpl_curve + assert mpl.ann_r2.get_visible() + text = mpl.ann_r2.get_text() + assert text.startswith("R²") + r2_value = float(text.split("=")[1].strip()) + assert 0.0 <= r2_value <= 1.0 + main_window.close()