From 5298a39b62363851425c31c185578af38a02d3fc Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Tue, 23 Jun 2026 13:34:06 +0200 Subject: [PATCH 1/6] fix: ensure fitall and click do the same thing --- CHANGELOG | 1 + pyjibe/fd/main.py | 2 ++ tests/test_fd_fit.py | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index fda13cc..4a96cf7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ 0.16.4 + - fix: ensure identical results when clicking per file and compute button (#47, #51) - 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..7c5a1ce 100644 --- a/pyjibe/fd/main.py +++ b/pyjibe/fd/main.py @@ -512,6 +512,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/tests/test_fd_fit.py b/tests/test_fd_fit.py index 5f37733..72ee23d 100644 --- a/tests/test_fd_fit.py +++ b/tests/test_fd_fit.py @@ -262,3 +262,74 @@ 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() From 7af0499538afea01059da9e5fa97f913c9bfc93b Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Tue, 23 Jun 2026 13:40:07 +0200 Subject: [PATCH 2/6] tests: click both files and reduce docstring --- tests/test_fd_fit.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/test_fd_fit.py b/tests/test_fd_fit.py index 72ee23d..46378bb 100644 --- a/tests/test_fd_fit.py +++ b/tests/test_fd_fit.py @@ -265,12 +265,7 @@ def test_set_indentation_depth_manually_infdoublespinbox(qtbot): 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. - """ + """on_fit_all must produce the same fitted params as fitting one-by-one.""" main_window = pyjibe.head.PyJibe() qtbot.addWidget(main_window) main_window.load_data(files=make_directory_with_data(2)) @@ -279,8 +274,11 @@ def test_fit_all_matches_single_fit(qtbot): 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() + # Click through both files via the single-curve path + cl1 = war.list_curves.currentItem() + cl2 = war.list_curves.itemBelow(cl1) + war.list_curves.setCurrentItem(cl1) + war.list_curves.setCurrentItem(cl2) fdist0 = war.data_set[0] assert fdist0.fit_properties.get("success", False), ( "single fit must succeed") @@ -299,11 +297,8 @@ 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. + a preliminary Hertz model to each curve. The KVM model is loaded via + PyJibe's extension system at startup. """ main_window = pyjibe.head.PyJibe() qtbot.addWidget(main_window) @@ -319,8 +314,11 @@ def test_fit_all_matches_single_fit_kvm(qtbot): 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() + # Click through both files via the single-curve path + cl1 = war.list_curves.currentItem() + cl2 = war.list_curves.itemBelow(cl1) + war.list_curves.setCurrentItem(cl1) + war.list_curves.setCurrentItem(cl2) 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 From 4188d4da693ed27df4079c447df37410cb9affd3 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Tue, 23 Jun 2026 13:41:44 +0200 Subject: [PATCH 3/6] update README with ai section --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index cded422..93c8610 100644 --- a/README.rst +++ b/README.rst @@ -36,3 +36,8 @@ If you have Python installed, you may also install via :target: https://codecov.io/gh/AFM-analysis/PyJibe .. |Docs Status| image:: https://readthedocs.org/projects/pyjibe/badge/?version=latest :target: https://readthedocs.org/projects/pyjibe/builds/ + +Use of AI +--------- + +The model Claude Sonnet 4.6 was used to fix bugs such as #47. From 81409970ffd4494261cba49caabbbaf6cb1badff Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 24 Jun 2026 14:06:40 +0200 Subject: [PATCH 4/6] fix: correctly handle swap to new model --- pyjibe/fd/tab_fit.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/pyjibe/fd/tab_fit.py b/pyjibe/fd/tab_fit.py index 65d8464..2f18f7b 100644 --- a/pyjibe/fd/tab_fit.py +++ b/pyjibe/fd/tab_fit.py @@ -115,10 +115,14 @@ def anc_update_parameters(self, fdist): if (atab.item(row, 0).checkState() == QtCore.Qt.CheckState.Checked): # update initial parameters + # (block signals to prevent recursive on_params_init + # while applying ancillary values to itab) + itab.blockSignals(True) for rr in range(itab.rowCount()): if itab.verticalHeaderItem(rr).text() == label: if value_text != "nan": itab.item(rr, 1).setText(value_text) + itab.blockSignals(False) row += 1 atab.blockSignals(False) else: @@ -345,14 +349,21 @@ def fit_update_parameters(self, fdist): # set the model # - resets params_initial if model changed # - important for computing ancillary parameters + prev_model_key = fdist.fit_properties.get("model_key") fdist.fit_properties["model_key"] = model_key - if fdist.fit_properties.get("params_initial", False): - # (cannot coerce this into one line, because "params_initial" - # can be None.) + if prev_model_key != model_key: + # Clear the ancillary cache so anc_update_parameters gets fresh + # ancillaries for the new model (the cache may contain stale + # values from the previous model's fit) + fdist._anc_cache = None + if (fdist.fit_properties.get("params_initial", False) + and prev_model_key == model_key): # set the parameters of the previous fit params = fdist.fit_properties["params_initial"] else: - # use the initial model parameters + # use the initial model parameters (also when model changed, to + # ensure itab is set up with the new model's parameter labels so + # that anc_update_parameters can correctly apply ancillary values) params = self.fit_parameters() # parameter table From 984f5f92365e96d2a025143ae3211d28dadab4f0 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 24 Jun 2026 14:06:54 +0200 Subject: [PATCH 5/6] tests: check tsv output file rather than data array --- tests/test_fd_fit.py | 46 +++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/test_fd_fit.py b/tests/test_fd_fit.py index 46378bb..53bc3af 100644 --- a/tests/test_fd_fit.py +++ b/tests/test_fd_fit.py @@ -265,12 +265,16 @@ def test_set_indentation_depth_manually_infdoublespinbox(qtbot): def test_fit_all_matches_single_fit(qtbot): - """on_fit_all must produce the same fitted params as fitting one-by-one.""" + """on_fit_all must produce the same TSV output as fitting one-by-one.""" + files = make_directory_with_data(2) + tsv_path = files[0].parent / "pyjibe_fit_results_leaf.tsv" + main_window = pyjibe.head.PyJibe() qtbot.addWidget(main_window) - main_window.load_data(files=make_directory_with_data(2)) + main_window.load_data(files=files) war = main_window.subwindows[0].widget() - war.cb_autosave.setChecked(0) + war.cb_autosave.setChecked(True) + war._autosave_override = 1 # always overwrite, avoid dialog war.tab_preprocess.set_preprocessing(["compute_tip_position"]) war.tab_fit.cb_weight_cp.setCheckState(QtCore.Qt.CheckState.Unchecked) @@ -279,32 +283,33 @@ def test_fit_all_matches_single_fit(qtbot): cl2 = war.list_curves.itemBelow(cl1) war.list_curves.setCurrentItem(cl1) war.list_curves.setCurrentItem(cl2) - 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 + assert tsv_path.exists(), "autosave TSV not created after single-fit" + single_tsv = tsv_path.read_text() - # Now re-fit everything via fit-all + # Re-fit everything via fit-all; TSV must be byte-for-byte identical war.on_fit_all() - fitall_E = fdist0.fit_properties["params_fitted"]["E"].value + fitall_tsv = tsv_path.read_text() - 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}") + assert single_tsv == fitall_tsv main_window.close() def test_fit_all_matches_single_fit_kvm(qtbot): - """fit-all must match single-fit for the KVM model (issue #47). + """fit-all must produce the same TSV as single-fit for the KVM model. KVM has ancillary parameters (eta, time_ind) that are derived by fitting a preliminary Hertz model to each curve. The KVM model is loaded via PyJibe's extension system at startup. """ + files = make_directory_with_data(2) + tsv_path = files[0].parent / "pyjibe_fit_results_leaf.tsv" + main_window = pyjibe.head.PyJibe() qtbot.addWidget(main_window) - main_window.load_data(files=make_directory_with_data(2)) + main_window.load_data(files=files) war = main_window.subwindows[0].widget() - war.cb_autosave.setChecked(0) + war.cb_autosave.setChecked(True) + war._autosave_override = 1 # always overwrite, avoid dialog war.tab_preprocess.set_preprocessing(["compute_tip_position"]) war.tab_fit.cb_weight_cp.setCheckState(QtCore.Qt.CheckState.Unchecked) @@ -319,15 +324,12 @@ def test_fit_all_matches_single_fit_kvm(qtbot): cl2 = war.list_curves.itemBelow(cl1) war.list_curves.setCurrentItem(cl1) war.list_curves.setCurrentItem(cl2) - 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 + assert tsv_path.exists(), "autosave TSV not created after single-fit" + single_tsv = tsv_path.read_text() - # Re-fit everything via fit-all + # Re-fit everything via fit-all; TSV must be byte-for-byte identical war.on_fit_all() - fitall_Eu = fdist0.fit_properties["params_fitted"]["Eu"].value + fitall_tsv = tsv_path.read_text() - 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}") + assert single_tsv == fitall_tsv main_window.close() From 1d18d3ef801ab9d7e4ba4bbc60d3dbff8a1cdc08 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 24 Jun 2026 14:08:35 +0200 Subject: [PATCH 6/6] update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 4a96cf7..501a51f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ 0.16.4 + - fix: correctly handle params states when swapping to new model (#51) - fix: ensure identical results when clicking per file and compute button (#47, #51) - setup: support latest python versions (#32, #46) - setup: support latest matplotlib versions (#32, #46)