diff --git a/CHANGELOG b/CHANGELOG index fda13cc..501a51f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,6 @@ 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) - fix: remember user's choice upon curve open (#44, #45) 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. 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/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 diff --git a/tests/test_fd_fit.py b/tests/test_fd_fit.py index 5f37733..53bc3af 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 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=files) + war = main_window.subwindows[0].widget() + 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) + + # 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) + assert tsv_path.exists(), "autosave TSV not created after single-fit" + single_tsv = tsv_path.read_text() + + # Re-fit everything via fit-all; TSV must be byte-for-byte identical + war.on_fit_all() + fitall_tsv = tsv_path.read_text() + + assert single_tsv == fitall_tsv + 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. + + 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=files) + war = main_window.subwindows[0].widget() + 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) + + # 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) + + # 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) + assert tsv_path.exists(), "autosave TSV not created after single-fit" + single_tsv = tsv_path.read_text() + + # Re-fit everything via fit-all; TSV must be byte-for-byte identical + war.on_fit_all() + fitall_tsv = tsv_path.read_text() + + assert single_tsv == fitall_tsv + main_window.close()