From 7b074d30ebda5259457f8aee50b923c60b5ecd78 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:37:54 -0400 Subject: [PATCH] feat: add HCSWizard to MDAWIdget (#362) * PlateDatabaseWidget * delete some stuff * add example * running * wip * lots of cleanup * style(pre-commit.ci): auto fixes [...] * more tweaks * feat: wip PlateTestWidget * fix: wip * feat: click wip * feat: working stage movements + example * feat: preset movements * fix: _xy_label * fix: SELECTED_MOVE_TO_COLOR * fix: _MoveToItem size and color * fix: refactor * fix: adjust position if rotation * fix: invert rotated_y sign * fix: rename + fix _PresetPositionItem size * fix: update example with rotation * fix: remove print * test: wip * fix: use qtpy * test: update * test: update * fix: remove print * test: update * test: update * test: fix * fix: move test into hcs folder * fix: rename * fix: clear * fix: setPlate * fix: update layout * fix: margins * fix: spacing * feat: add well name to _xy_label * fix: spacing * unifying features * feat: calibration tab + _test_well_btn * fix: use _current_calibration_widget * fix: rename + random test points * fix: wip * changes * fix: update setPlate method * fix: refactor * fix: remove unused * fix: _add_preset_positions_items * fix: _get_random_edge_point * test: update tests * test: update tests * style(pre-commit.ci): auto fixes [...] * feat: add new calibration wdg * fix: rename methods * add _calibrated flag * rename to value and setValue * fix _on_plate_changed] * todo * add label * change click signal * minor * fix: add QGroupBox * fix: sizes + rotation * fix: accept * fix: HoverEllipse tooltip * fix: accept * fix: example * fix: example * fix: deselect wells with no explicit selection * undo last change * more changes in behavior * some docs * remove WellPlateWidgetNoRotation * supress runtime error * updates * fix: clear selection when plate changes * fix: emit signal when selecting a different plate * fix: update fov size when calling setValue * fix: test_well_btn no focus policy * fix: fix bug when setting selected_wells * test: fix * feat: start adding HCSWizard * feat: add hcs edit positions btn * fix: z column * fix: move logic to core position table * test: start adding tests * test: more tests * feat: add position overwrite warning dialog * Update src/pymmcore_widgets/mda/_core_positions.py * undo change * Refactor HCS wizard integration in CoreConnectedPositionTable * fix test * more updates * test: add failing test * fix: fix signal emitted too many times * fix: keep AF checkbox visible * test: update * test: comment --------- Co-authored-by: Talley Lambert Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/mda_widget.py | 5 + src/pymmcore_widgets/mda/_core_mda.py | 18 ++ src/pymmcore_widgets/mda/_core_positions.py | 183 +++++++++++++++++- .../useq_widgets/_positions.py | 20 +- tests/test_useq_core_widgets.py | 129 +++++++++++- 5 files changed, 334 insertions(+), 21 deletions(-) diff --git a/examples/mda_widget.py b/examples/mda_widget.py index a0d01e34e..8379f03b4 100644 --- a/examples/mda_widget.py +++ b/examples/mda_widget.py @@ -3,12 +3,17 @@ It is fully connected to the CMMCorePlus object, and has a "run" button. """ +from contextlib import suppress + import useq from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QApplication from pymmcore_widgets import MDAWidget +with suppress(ImportError): + from rich import print + app = QApplication([]) CMMCorePlus.instance().loadSystemConfiguration() diff --git a/src/pymmcore_widgets/mda/_core_mda.py b/src/pymmcore_widgets/mda/_core_mda.py index 18e7e71b2..ffc7b8c07 100644 --- a/src/pymmcore_widgets/mda/_core_mda.py +++ b/src/pymmcore_widgets/mda/_core_mda.py @@ -111,6 +111,24 @@ def __init__( self.destroyed.connect(self._disconnect) + # ----------- Override type hints in superclass ----------- + + @property + def channels(self) -> CoreConnectedChannelTable: + return cast("CoreConnectedChannelTable", self.tab_wdg.channels) + + @property + def z_plan(self) -> CoreConnectedZPlanWidget: + return cast("CoreConnectedZPlanWidget", self.tab_wdg.z_plan) + + @property + def stage_positions(self) -> CoreConnectedPositionTable: + return cast("CoreConnectedPositionTable", self.tab_wdg.stage_positions) + + @property + def grid_plan(self) -> CoreConnectedGridPlanWidget: + return cast("CoreConnectedGridPlanWidget", self.tab_wdg.grid_plan) + # ------------------- public Methods ---------------------- def value(self) -> MDASequence: diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index 349245f1a..aac73b8c5 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -1,20 +1,37 @@ from __future__ import annotations -from typing import Any +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Sequence from fonticon_mdi6 import MDI6 from pymmcore_plus import CMMCorePlus from pymmcore_plus._logger import logger from pymmcore_plus._util import retry -from qtpy.QtWidgets import QCheckBox, QMessageBox, QWidget, QWidgetAction +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QCheckBox, + QMessageBox, + QPushButton, + QWidget, + QWidgetAction, + QWizard, +) from superqt.utils import signals_blocked +from useq import WellPlatePlan +from pymmcore_widgets import HCSWizard from pymmcore_widgets.useq_widgets import PositionTable from pymmcore_widgets.useq_widgets._column_info import ( ButtonColumn, ) from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP +if TYPE_CHECKING: + from useq import Position + +UPDATE_POSITIONS = "Update Positions List" +ADD_POSITIONS = "Add to Positions List" + class CoreConnectedPositionTable(PositionTable): """[PositionTable](../PositionTable#) connected to a Micro-Manager core instance. @@ -45,6 +62,28 @@ def __init__( super().__init__(rows, parent) self._mmc = mmcore or CMMCorePlus.instance() + # -------------- HCS Wizard ---------------- + self._hcs_wizard: HCSWizard | None = None + self._plate_plan: WellPlatePlan | None = None + + self._hcs_button = QPushButton("Well Plate...") + # self._hcs_button.setIcon(icon(MDI6.view_comfy)) + self._hcs_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._hcs_button.setToolTip("Open the HCS wizard.") + self._hcs_button.clicked.connect(self._show_hcs) + + self._edit_hcs_pos = QPushButton("Make Editable") + self._edit_hcs_pos.setToolTip( + "Convert HCS positions to regular editable positions." + ) + self._edit_hcs_pos.setStyleSheet("color: red") + self._edit_hcs_pos.hide() + self._edit_hcs_pos.clicked.connect(self._show_pos_editing_dialog) + + self._btn_row.insertWidget(3, self._hcs_button) + self._btn_row.insertWidget(3, self._edit_hcs_pos) + # ------------------------------------------ + self.move_to_selection = QCheckBox("Move Stage to Selected Point") # add a button to update XY to the current position self._xy_btn_col = ButtonColumn( @@ -53,19 +92,20 @@ def __init__( self._z_btn_col = ButtonColumn( key="z_btn", glyph=MDI6.arrow_left, on_click=self._set_z_from_core ) - self.table().addColumn(self._xy_btn_col, self.table().indexOf(self.X)) - self.table().addColumn(self._z_btn_col, self.table().indexOf(self.Z) + 1) - self.table().addColumn(self._af_btn_col, self.table().indexOf(self.AF) + 1) + table = self.table() + table.addColumn(self._xy_btn_col, table.indexOf(self.X)) + table.addColumn(self._z_btn_col, table.indexOf(self.Z) + 1) + table.addColumn(self._af_btn_col, table.indexOf(self.AF) + 1) # when a new row is inserted, call _on_rows_inserted # to update the new values from the core position - self.table().model().rowsInserted.connect(self._on_rows_inserted) + table.model().rowsInserted.connect(self._on_rows_inserted) # add move_to_selection to toolbar and link up callback toolbar = self.toolBar() action0 = next(x for x in toolbar.children() if isinstance(x, QWidgetAction)) toolbar.insertWidget(action0, self.move_to_selection) - self.table().itemSelectionChanged.connect(self._on_selection_change) + table.itemSelectionChanged.connect(self._on_selection_change) # connect self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_config_loaded) @@ -77,8 +117,137 @@ def __init__( # hide the set-AF-offset button to begin with. self._on_af_per_position_toggled(self.af_per_position.isChecked()) + # ---------------------- public methods ----------------------- + + def value( + self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True + ) -> tuple[Position, ...] | WellPlatePlan: + """Return the current state of the positions table.""" + if self._plate_plan is not None: + return self._plate_plan + return super().value(exclude_unchecked, exclude_hidden_cols) + + def setValue(self, value: Sequence[Position] | WellPlatePlan) -> None: + """Set the value of the positions table.""" + if isinstance(value, WellPlatePlan): + self._plate_plan = value + self._hcs.setValue(value) + self._set_position_table_editable(False) + value = tuple(value) + super().setValue(value) + # ----------------------- private methods ----------------------- + def _show_hcs(self) -> None: + """Show or raise the HCS wizard.""" + self._hcs.raise_() if self._hcs.isVisible() else self._hcs.show() + + @property + def _hcs(self) -> HCSWizard: + """Get the HCS wizard, initializing it if it doesn't exist.""" + if self._hcs_wizard is None: + self._hcs_wizard = HCSWizard(self) + self._rename_hcs_position_button(ADD_POSITIONS) + self._hcs_wizard.accepted.connect(self._on_hcs_accepted) + return self._hcs_wizard + + def _on_hcs_accepted(self) -> None: + """Add the positions from the HCS wizard to the stage positions.""" + self._plate_plan = self._hcs.value() + if self._plate_plan is not None: + # show a ovwerwrite warning dialog if the table is not empty + if self.table().rowCount() > 0: + dialog = QMessageBox( + QMessageBox.Icon.Warning, + "Overwrite Positions", + "This will replace the positions currently stored in the table." + "\nWould you like to proceed?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + self, + ) + dialog.setDefaultButton(QMessageBox.StandardButton.Yes) + if dialog.exec() != QMessageBox.StandardButton.Yes: + return + self._update_table_positions(self._plate_plan) + + def _update_table_positions(self, plan: WellPlatePlan) -> None: + """Update the table with the positions from the HCS wizard.""" + self.setValue(list(plan)) + self._set_position_table_editable(False) + + def _rename_hcs_position_button(self, text: str) -> None: + if wiz := self._hcs_wizard: + wiz.points_plan_page.setButtonText(QWizard.WizardButton.FinishButton, text) + + def _show_pos_editing_dialog(self) -> None: + dialog = QMessageBox( + QMessageBox.Icon.Warning, + "Reset HCS", + "Positions are currently autogenerated from the HCS Wizard." + "\n\nWould you like to cast them to a list of stage positions?" + "\n\nNOTE: you will no longer be able to edit them using the HCS Wizard " + "widget.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + self, + ) + dialog.setDefaultButton(QMessageBox.StandardButton.No) + if dialog.exec() == QMessageBox.StandardButton.Yes: + self._plate_plan = None + self._set_position_table_editable(True) + + def _set_position_table_editable(self, state: bool) -> None: + """Enable/disable the position table depending on the use of the HCS wizard.""" + self._edit_hcs_pos.setVisible(not state) + self.include_z.setVisible(state) + + # Hide or show all columns that are irrelevant when using the HCS wizard + table = self.table() + inc_z = self.include_z.isChecked() + table.setColumnHidden(table.indexOf(self._xy_btn_col), not state) + table.setColumnHidden(table.indexOf(self._z_btn_col), not state or not inc_z) + table.setColumnHidden(table.indexOf(self.Z), not state or not inc_z) + table.setColumnHidden(table.indexOf(self.SEQ), not state) + + # Enable or disable the toolbar + for action in self.toolBar().actions()[1:]: + action.setEnabled(state) + + self._enable_table_items(state) + # connect/disconnect the double click event and rename the button + if state: + self._rename_hcs_position_button(ADD_POSITIONS) + with suppress(RuntimeError): + self.table().cellDoubleClicked.disconnect(self._show_pos_editing_dialog) + else: + self._rename_hcs_position_button(UPDATE_POSITIONS) + # using UniqueConnection to avoid multiple connections + # but catching the TypeError if the connection is already made + with suppress(TypeError, RuntimeError): + self.table().cellDoubleClicked.connect( + self._show_pos_editing_dialog, Qt.ConnectionType.UniqueConnection + ) + + def _enable_table_items(self, state: bool) -> None: + """Enable or disable the table items depending on the use of the HCS wizard.""" + table = self.table() + name_col = table.indexOf(self.NAME) + x_col = table.indexOf(self.X) + y_col = table.indexOf(self.Y) + with signals_blocked(table): + for row in range(table.rowCount()): + table.cellWidget(row, x_col).setEnabled(state) + table.cellWidget(row, y_col).setEnabled(state) + # enable/disable the name cells + name_item = table.item(row, name_col) + flags = name_item.flags() | Qt.ItemFlag.ItemIsEnabled + if state: + flags |= Qt.ItemFlag.ItemIsEditable + else: + # keep the name column enabled but NOT editable. We do not disable + # to keep available the "Move Stage to Selected Point" option + flags &= ~Qt.ItemFlag.ItemIsEditable + name_item.setFlags(flags) + def _on_sys_config_loaded(self) -> None: """Update the table when the system configuration is loaded.""" self._update_xy_enablement() diff --git a/src/pymmcore_widgets/useq_widgets/_positions.py b/src/pymmcore_widgets/useq_widgets/_positions.py index 57bcf7639..78f7f4c25 100644 --- a/src/pymmcore_widgets/useq_widgets/_positions.py +++ b/src/pymmcore_widgets/useq_widgets/_positions.py @@ -176,22 +176,22 @@ def __init__(self, rows: int = 0, parent: QWidget | None = None): self._load_button = QPushButton("Load...") self._load_button.clicked.connect(self.load) - btn_row = QHBoxLayout() - btn_row.setSpacing(15) - btn_row.addWidget(self.include_z) - btn_row.addWidget(self.af_per_position) - btn_row.addStretch() - btn_row.addWidget(self._save_button) - btn_row.addWidget(self._load_button) + self._btn_row = QHBoxLayout() + self._btn_row.setSpacing(15) + self._btn_row.addWidget(self.include_z) + self._btn_row.addWidget(self.af_per_position) + self._btn_row.addStretch() + self._btn_row.addWidget(self._save_button) + self._btn_row.addWidget(self._load_button) layout = cast("QVBoxLayout", self.layout()) - layout.addLayout(btn_row) + layout.addLayout(self._btn_row) # ------------------------- Public API ------------------------- def value( self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True - ) -> tuple[useq.Position, ...]: + ) -> Sequence[useq.Position]: """Return the current value of the table as a tuple of [useq.Position](https://pymmcore-plus.github.io/useq-schema/schema/axes/#useq.Position). Parameters @@ -235,7 +235,7 @@ def value( return tuple(out) - def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore + def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore [override] """Set the current value of the table from a Sequence of [useq.Position](https://pymmcore-plus.github.io/useq-schema/schema/axes/#useq.Position). Parameters diff --git a/tests/test_useq_core_widgets.py b/tests/test_useq_core_widgets.py index 250e7612e..63680d1f5 100644 --- a/tests/test_useq_core_widgets.py +++ b/tests/test_useq_core_widgets.py @@ -2,13 +2,14 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, cast -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import useq from qtpy.QtCore import QTimer from qtpy.QtWidgets import QMessageBox +from pymmcore_widgets import HCSWizard from pymmcore_widgets._util import get_next_available_path from pymmcore_widgets.mda import MDAWidget from pymmcore_widgets.mda._core_channels import CoreConnectedChannelTable @@ -20,6 +21,7 @@ PYMMCW_METADATA_KEY, AutofocusAxis, KeepShutterOpen, + QFileDialog, ) from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP, _MDAPopup @@ -238,7 +240,7 @@ def _getAutoFocusOffset(): def test_core_position_table_add_position( - qtbot: QtBot, mock_getAutoFocusOffset + qtbot: QtBot, mock_getAutoFocusOffset: None ) -> None: wdg = MDAWidget() qtbot.addWidget(wdg) @@ -579,8 +581,6 @@ def test_mda_no_pos_set(global_mmcore: CMMCorePlus, qtbot: QtBot): def test_core_mda_wdg_load_save( qtbot: QtBot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ext: str ) -> None: - from pymmcore_widgets.useq_widgets._mda_sequence import QFileDialog - wdg = MDAWidget() qtbot.addWidget(wdg) wdg.show() @@ -686,3 +686,124 @@ def test_get_next_available_paths_special_cases(tmp_path: Path) -> None: high = tmp_path / "test_12345.txt" high.touch() assert get_next_available_path(high).name == "test_12346.txt" + + +def test_core_mda_with_hcs_value(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: + wdg = MDAWidget() + qtbot.addWidget(wdg) + wdg.show() + + # uncheck all tabs + for t in range(wdg.tab_wdg.count() + 1): + wdg.tab_wdg.setChecked(t, False) + + assert wdg.stage_positions._hcs_wizard is None + assert wdg.stage_positions._plate_plan is None + + pos = useq.WellPlatePlan( + plate="96-well", a1_center_xy=(0, 0), selected_wells=((0, 1), (0, 1)) + ) + seq = useq.MDASequence(stage_positions=pos) + + mock = Mock() + wdg.valueChanged.connect(mock) + wdg.setValue(seq) + mock.assert_called_once() + + assert wdg.value().stage_positions == pos + assert wdg.stage_positions.table().rowCount() == len(pos) + + assert isinstance(wdg.stage_positions._hcs_wizard, HCSWizard) + assert wdg.stage_positions._plate_plan == pos + + +def test_core_mda_with_hcs_enable_disable( + qtbot: QtBot, global_mmcore: CMMCorePlus +) -> None: + wdg = MDAWidget() + qtbot.addWidget(wdg) + wdg.show() + + table = wdg.stage_positions.table() + name_col = table.indexOf(wdg.stage_positions.NAME) + xy_btn_col = table.indexOf(wdg.stage_positions._xy_btn_col) + z_btn_col = table.indexOf(wdg.stage_positions._z_btn_col) + z_col = table.indexOf(wdg.stage_positions.Z) + sub_seq_btn_col = table.indexOf(wdg.stage_positions.SEQ) + + mda = useq.MDASequence(stage_positions=[(0, 0, 0), (1, 1, 1)]) + wdg.setValue(mda) + + # edit table btn is hidden + assert wdg.stage_positions._edit_hcs_pos.isHidden() + # all table visible + assert not table.isColumnHidden(name_col) + assert not table.isColumnHidden(xy_btn_col) + assert not table.isColumnHidden(z_btn_col) + assert not table.isColumnHidden(z_col) + assert not table.isColumnHidden(sub_seq_btn_col) + # all toolbar actions enabled + assert all(action.isEnabled() for action in wdg.stage_positions.toolBar().actions()) + # include_z checkbox enabled + assert wdg.stage_positions.include_z.isEnabled() + # autofocus checkbox enabled + assert wdg.stage_positions.af_per_position.isEnabled() + + mda = useq.MDASequence( + stage_positions=useq.WellPlatePlan( + plate="96-well", + a1_center_xy=(0, 0), + selected_wells=((0, 1), (0, 1)), + ) + ) + wdg.setValue(mda) + + # edit table btn is visible + assert not wdg.stage_positions._edit_hcs_pos.isHidden() + # all columns hidden but name + assert not table.isColumnHidden(name_col) + assert table.isColumnHidden(xy_btn_col) + assert table.isColumnHidden(z_btn_col) + assert table.isColumnHidden(z_col) + assert table.isColumnHidden(sub_seq_btn_col) + # all toolbar actions disabled but the move stage checkbox + assert all( + not action.isEnabled() for action in wdg.stage_positions.toolBar().actions()[1:] + ) + # include_z checkbox disablex + assert wdg.stage_positions.include_z.isHidden() + # autofocus checkbox enabled + assert wdg.stage_positions.af_per_position.isEnabled() + + +@pytest.mark.parametrize("ext", ["json", "yaml"]) +def test_core_mda_with_hcs_load_save( + qtbot: QtBot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ext: str +) -> None: + wdg = MDAWidget() + qtbot.addWidget(wdg) + wdg.show() + + dest = tmp_path / f"sequence.{ext}" + # monkeypatch the dialog to load/save to our temp file + monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda *a: (dest, None)) + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a: (dest, None)) + + # write the sequence to file and load the widget from it + mda = MDA.replace( + stage_positions=useq.WellPlatePlan( + plate="96-well", + a1_center_xy=(0, 0), + selected_wells=((0, 0), (1, 1)), + well_points_plan=useq.RelativePosition(fov_width=512.0, fov_height=512.0), + ) + ) + dest.write_text(mda.yaml() if ext == "yaml" else mda.model_dump_json()) + wdg.load() + + pos = wdg.value().stage_positions + + # save the widget to file and load it back + dest.unlink() + wdg.save() + assert useq.MDASequence.from_file(dest).stage_positions == pos