Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
3 changes: 3 additions & 0 deletions pyjibe/fd/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions pyjibe/fd/main.ui
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@
<item>
<widget class="QWidget" name="plot_prefs" native="true">
<layout class="QVBoxLayout" name="verticalLayout_8">
<item>
<widget class="QCheckBox" name="cb_show_fit_line">
<property name="text">
<string>Show fit line</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cb_mpl_rescale_plot_x">
<property name="text">
Expand Down
20 changes: 18 additions & 2 deletions pyjibe/fd/mpl_indent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion pyjibe/fd/widget_plot_fd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
118 changes: 118 additions & 0 deletions tests/test_fd_fit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading