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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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) From 91836eae5db159ad39d5d3fd5fcd39be363bd694 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Mon, 6 Jul 2026 11:58:21 +0200 Subject: [PATCH 7/9] ref: cleanup readme --- README.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.rst b/README.rst index 93c8610..cded422 100644 --- a/README.rst +++ b/README.rst @@ -36,8 +36,3 @@ 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 a9d7b957a57269856678e2a4d620e2da1a520d78 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Mon, 6 Jul 2026 15:40:57 +0200 Subject: [PATCH 8/9] tests: add tests for each bug and model fitting output --- .gitignore | 3 + tests/test_fd_fit.py | 189 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7a092c7..a9041a3 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,6 @@ pyjibe/_version.py # uv uv.lock + +# ai +CLAUDE.md diff --git a/tests/test_fd_fit.py b/tests/test_fd_fit.py index 53bc3af..57a409c 100644 --- a/tests/test_fd_fit.py +++ b/tests/test_fd_fit.py @@ -294,6 +294,121 @@ def test_fit_all_matches_single_fit(qtbot): main_window.close() +def test_swap_model_does_not_raise_and_updates_labels(qtbot): + """Model swap must not crash and itab must show new model params. + + Notes + ----- + In nanite>=4.2.3, FitProperties.__setitem__ already resets + "params_initial" whenever "model_key" changes (see nanite/fit.py), + so the stale-params KeyError from 8140997 is not reproducible via + normal UI actions here. This guards against a crash/label + regression during model swap instead. + """ + 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"]) + + fdist = war.data_set[0] + assert fdist.fit_properties["model_key"] == "hertz_para" + assert "R" in fdist.fit_properties["params_initial"] + + pyr_name = nmodel.model_hertz_three_sided_pyramid.model_name + pyr_idx = war.tab_fit.cb_model.findText(pyr_name) + war.tab_fit.cb_model.setCurrentIndex(pyr_idx) # must not raise + + itab = war.tab_fit.table_parameters_initial + labels = [itab.verticalHeaderItem(rr).text() + for rr in range(itab.rowCount())] + assert any("Face Angle" in lb for lb in labels) + assert fdist.fit_properties["model_key"] == "hertz_pyr3s" + main_window.close() + + +def test_swap_model_recomputes_ancillary_not_cached(qtbot): + """Ancillary cache must be cleared (not reused) across a model swap.""" + with MockModelModule( + compute_ancillaries=lambda x: {"E": 1000}, + parameter_anc_keys=["E"], + parameter_anc_names=["ancillary E guess"], + parameter_anc_units=["Pa"], + model_key="test_anc_a"): + with MockModelModule( + compute_ancillaries=lambda x: {"E": 9999}, + parameter_anc_keys=["E"], + parameter_anc_names=["ancillary E guess"], + parameter_anc_units=["Pa"], + model_key="test_anc_b"): + + 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) + + idx_a = war.tab_fit.cb_model.findData("test_anc_a") + war.tab_fit.cb_model.setCurrentIndex(idx_a) + fdist = war.data_set[0] + + # real successful fit under model A populates _anc_cache + war.tab_fit.fit_approach_retract(fdist) + assert fdist.fit_properties.get("success") + war.tab_fit.anc_update_parameters(fdist) + assert fdist._anc_cache # sanity: cache now populated + + idx_b = war.tab_fit.cb_model.findData("test_anc_b") + war.tab_fit.cb_model.setCurrentIndex(idx_b) # must recompute + + atab = war.tab_fit.table_parameters_anc + assert atab.item(0, 1).text() == "9999" # fresh, not "1000" + main_window.close() + + +def test_anc_update_does_not_recurse_via_itab_signal(qtbot): + """Writing an ancillary value into itab must not re-trigger itself.""" + calls = [] + + def compute_anc(x): + calls.append(1) + return {"E": 4242} + + with MockModelModule( + compute_ancillaries=compute_anc, + parameter_anc_keys=["E"], + parameter_anc_names=["ancillary E guess"], + parameter_anc_units=["Pa"], + model_key="test_recursion"): + + 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"]) + idx = war.tab_fit.cb_model.findData("test_recursion") + war.tab_fit.cb_model.setCurrentIndex(idx) + + fdist = war.data_set[0] + itab = war.tab_fit.table_parameters_initial + itab.blockSignals(True) + itab.item(0, 1).setText("0") # real change, not a no-op + itab.blockSignals(False) + + calls.clear() + war.tab_fit.anc_update_parameters(fdist) + assert len(calls) == 1, ( + f"anc_update_parameters recursed via itab's itemChanged " + f"signal ({len(calls)} calls instead of 1)") + main_window.close() + + def test_fit_all_matches_single_fit_kvm(qtbot): """fit-all must produce the same TSV as single-fit for the KVM model. @@ -332,4 +447,76 @@ def test_fit_all_matches_single_fit_kvm(qtbot): fitall_tsv = tsv_path.read_text() assert single_tsv == fitall_tsv - main_window.close() + + +def test_swap_to_kvm_output_matches_unswapped_fit(qtbot): + """A confirmed fit under the default model, followed by a swap to + KVM, must not change KVM's ancillary-seeded initial defaults or + its fit output compared to swapping without that extra prior fit. + + KVM's "eta"/"time_ind" are fixed (non-varying) parameters seeded + from a real auxiliary fit (see compute_ancillaries in the KVM + extension) - unlike hertz_para/hertz_pyr3s used elsewhere in this + file, a stale ancillary cache here does not just cosmetically + mislabel the UI: it silently falls back to KVM's own hard-coded + literal defaults (eta=0.5, time_ind=1), which are baked into the + fitted model equation and change the final numeric fit output. + """ + tsv_name = "pyjibe_fit_results_leaf.tsv" + + def setup_window(files): + main_window = pyjibe.head.PyJibe() + qtbot.addWidget(main_window) + main_window.load_data(files=files) + war = main_window.subwindows[0].widget() + war.cb_autosave.setChecked(True) + war._autosave_override = 1 + war.tab_preprocess.set_preprocessing(["compute_tip_position"]) + war.tab_fit.cb_weight_cp.setCheckState( + QtCore.Qt.CheckState.Unchecked) + return main_window, war + + def swap_to_kvm_and_export(war, files): + 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) + fdist = war.data_set[0] + assert fdist.fit_properties.get("success") + kvm_defaults = war.tab_fit.fit_model.get_parameter_defaults() + + # eta/time_ind must be the freshly computed ancillary guesses, + # not KVM's own hard-coded literal defaults (which is what a + # stale/mismatched ancillary cache would silently fall back to) + itab = war.tab_fit.table_parameters_initial + eta_row = next( + rr for rr in range(itab.rowCount()) + if itab.verticalHeaderItem(rr).text().startswith("viscosity")) + time_row = next( + rr for rr in range(itab.rowCount()) + if itab.verticalHeaderItem(rr).text().startswith( + "Time to indent")) + assert (float(itab.item(eta_row, 1).text()) + != kvm_defaults["eta"].value) + assert (float(itab.item(time_row, 1).text()) + != kvm_defaults["time_ind"].value) + + return (files[0].parent / tsv_name).read_text() + + # path A: fit under the default model first, then swap to KVM + files_a = make_directory_with_data(1) + main_window_a, war_a = setup_window(files_a) + fdist_a = war_a.data_set[0] + war_a.tab_fit.fit_approach_retract(fdist_a) # confirmed hertz_para fit + assert fdist_a.fit_properties.get("success") + tsv_a = swap_to_kvm_and_export(war_a, files_a) + main_window_a.close() + + # path B: identical, but without the extra prior fit + files_b = make_directory_with_data(1) + main_window_b, war_b = setup_window(files_b) + tsv_b = swap_to_kvm_and_export(war_b, files_b) + main_window_b.close() + + assert tsv_a == tsv_b From fa3a3d6c3f3122a85163715f432c9930752878c4 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Tue, 7 Jul 2026 10:55:37 +0200 Subject: [PATCH 9/9] fix: last file didnt save rating in tsv when using on_fit_all button --- pyjibe/fd/main.py | 4 ++++ tests/test_fd_fit.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/pyjibe/fd/main.py b/pyjibe/fd/main.py index 7c5a1ce..4803fd8 100644 --- a/pyjibe/fd/main.py +++ b/pyjibe/fd/main.py @@ -523,6 +523,10 @@ def on_fit_all(self): try: # updating curve list may not be possible self.curve_list_update(item=ii) + # curve_list_update computes the rating; autosave + # again so the exported TSV reflects it (mirrors + # on_curve_list, which does the same) + self.autosave(fdist) except BaseException as e: logger.error(traceback.format_exc()) errored.append([fdist.path, e.__class__.__name__, e.args]) diff --git a/tests/test_fd_fit.py b/tests/test_fd_fit.py index 57a409c..11dc66e 100644 --- a/tests/test_fd_fit.py +++ b/tests/test_fd_fit.py @@ -294,6 +294,36 @@ def test_fit_all_matches_single_fit(qtbot): main_window.close() +def test_fit_all_rates_last_curve_before_final_autosave(qtbot): + """on_fit_all must not leave the last curve's rating as nan""" + 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=files) + war = main_window.subwindows[0].widget() + war.cb_autosave.setChecked(True) + war._autosave_override = 1 + war.tab_preprocess.set_preprocessing(["compute_tip_position"]) + war.tab_fit.cb_weight_cp.setCheckState(QtCore.Qt.CheckState.Unchecked) + + # call fit-all directly on freshly-loaded curves - no prior + # single-click pass + war.on_fit_all() + + rows = tsv_path.read_text().splitlines() + header = rows[0].split("\t") + regressor_col = header.index("Regressor") + rating_col = header.index("Rating") + assert len(rows) == 3 # header + 2 curves + for row in rows[1:]: + cells = row.split("\t") + assert cells[regressor_col] != "nan" + assert cells[rating_col] != "nan" + main_window.close() + + def test_swap_model_does_not_raise_and_updates_labels(qtbot): """Model swap must not crash and itab must show new model params.