From 013e23f389f557ff7c0c8cc4a9b0852df9c1cefa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 07:18:54 +0000 Subject: [PATCH 1/2] Notebook stepper: 13 steps, one at a time (PR 17a) New notebook_stepper (strip of chips, Back/Next, one page shown) and notebook_steps (the decided order, pure composition of the existing cards; PreprocessingNotebook.steps()). The names rows move to notebook_names so the conditions and the replicate names sit on their own steps; the image type gets its own page (ImageTypeRows, the same answers). The shipped notebook shows notebook.steps() and results_step(). Text fixes: no stale RGB sentence in a described run, no broken-image icon before the preview, "above" not "below", reader and CSV lines behind Details, "folder" not "unit", step references to the new numbering. Box plots and curves order replicates by replicate ID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- notebooks/MorphoBricks_Preprocessing.ipynb | 15 +- pyproject.toml | 4 + .../notebook_axis_preview.py | 2 + .../notebook_axis_step.py | 2 + .../notebook_channels.py | 2 +- .../notebook_channels_grid.py | 41 +-- .../notebook_channels_labels.py | 4 +- .../notebook_channels_type.py | 104 ++++++- .../notebook_check_save.py | 16 +- .../notebook_check_view.py | 20 +- .../notebook_description.py | 127 +------- .../notebook_mask_question.py | 2 +- .../notebook_masks_screen.py | 2 +- .../notebook_names.py | 156 ++++++++++ .../notebook_plots.py | 12 +- .../notebook_step_states.py | 206 +++++++++++++ .../notebook_stepper.py | 196 +++++++++++++ .../notebook_steps.py | 273 ++++++++++++++++++ src/morphobricks_preprocessing/notebook_ui.py | 7 +- src/morphobricks_preprocessing/run_labels.py | 8 + tests/test_embedding_api.py | 1 + tests/test_notebook_channels.py | 16 +- tests/test_notebook_check_save.py | 4 +- tests/test_notebook_file.py | 12 +- tests/test_run_page_labels.py | 10 + 25 files changed, 1047 insertions(+), 195 deletions(-) create mode 100644 src/morphobricks_preprocessing/notebook_names.py create mode 100644 src/morphobricks_preprocessing/notebook_step_states.py create mode 100644 src/morphobricks_preprocessing/notebook_stepper.py create mode 100644 src/morphobricks_preprocessing/notebook_steps.py diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb index 062498d3..20fcccfa 100644 --- a/notebooks/MorphoBricks_Preprocessing.ipynb +++ b/notebooks/MorphoBricks_Preprocessing.ipynb @@ -30,8 +30,8 @@ "In the app:\n", "\n", "1. Give the **experiment folder** and optional **output/masks folders**, then click **Check folder**.\n", - "2. Confirm the labels/design and review the axis roles and image type.\n", - "3. Click **Compute histograms**.\n", + "2. Go through the steps one at a time with **Next** (or click a step in the strip at the top): axes, pixel sizes, conditions, replicates, comparisons, image type, stains, channel tags and masks.\n", + "3. On **Check and save**, read your experiment in words and save it; then **Run** computes the histograms.\n", "4. Review the QC results; pooled statistics are optional.\n", "\n", "Detailed explanations appear inside the app only where a decision is required.\n" @@ -313,16 +313,7 @@ "from IPython.display import display\n", "\n", "app = widgets.VBox(\n", - " [\n", - " notebook.welcome(),\n", - " notebook.folder_step(),\n", - " notebook.axis_step(),\n", - " notebook.channels_step(),\n", - " notebook.masks_step(),\n", - " notebook.check_save_step(),\n", - " notebook.run_step(),\n", - " notebook.results_step(),\n", - " ],\n", + " [notebook.welcome(), notebook.steps(), notebook.results_step()],\n", " layout=widgets.Layout(width=\"99%\"),\n", ")\n", "display(app)" diff --git a/pyproject.toml b/pyproject.toml index e7d2c7dd..7c73ab66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,6 +150,10 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.notebook_description" = "ui" "morphobricks_preprocessing.notebook_description_saved" = "ui" "morphobricks_preprocessing.notebook_description_view" = "ui" +"morphobricks_preprocessing.notebook_names" = "ui" +"morphobricks_preprocessing.notebook_step_states" = "ui" +"morphobricks_preprocessing.notebook_stepper" = "ui" +"morphobricks_preprocessing.notebook_steps" = "ui" "morphobricks_preprocessing.notebook_design" = "ui" "morphobricks_preprocessing.notebook_masks" = "ui" "morphobricks_preprocessing.notebook_masks_screen" = "ui" diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py index 4e3c2474..50ae8c82 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -90,11 +90,13 @@ def _render_preview(self) -> None: ) except (PlaneReadError, IndexError, ValueError) as exc: self.preview_image.value = b"" + ui.set_visible(self.preview_image, False) self.preview_caption.value = ui.status( "warn", "This plane cannot be shown with these roles.", [str(exc)] ) return self.preview_image.value = plane_png(plane) + ui.set_visible(self.preview_image, True) self.preview_caption.value = self._preview_label() + preview_caption( header.relative_path, plane ) diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 0fcc4aed..a5328478 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -89,6 +89,7 @@ def _build_preview_card(self) -> None: tooltip="Read one plane with the roles above and show it", ) self.preview_image = widgets.Image(format="png", layout=widgets.Layout(max_width="420px")) + ui.set_visible(self.preview_image, False) # no broken-picture icon before a preview self.preview_caption = widgets.HTML() self.preview_checked = ui.ConfirmToggle( "Does the preview look like a normal image of your sample?", @@ -246,6 +247,7 @@ def _reset_preview(self) -> None: for slider in self.preview_sliders.values(): ui.set_visible(slider, False) self.preview_image.value = b"" + ui.set_visible(self.preview_image, False) self.preview_caption.value = "" self.confirmed_status.value = "" diff --git a/src/morphobricks_preprocessing/notebook_channels.py b/src/morphobricks_preprocessing/notebook_channels.py index 49fdff9c..fdb8b2a6 100644 --- a/src/morphobricks_preprocessing/notebook_channels.py +++ b/src/morphobricks_preprocessing/notebook_channels.py @@ -28,7 +28,7 @@ INTRO = ( "What happens here: first name what you stained (the labels), then say, for every " - "unit, which label each channel is, looking at its picture. Nothing is chosen for you: " + "folder, which label each channel is, looking at its picture. Nothing is chosen for you: " "names found in the image headers are offered only as suggestions, and each one needs your " "click." ) diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py index 8a10795e..472781ce 100644 --- a/src/morphobricks_preprocessing/notebook_channels_grid.py +++ b/src/morphobricks_preprocessing/notebook_channels_grid.py @@ -51,8 +51,8 @@ def partial_text(label: str, used: int, total: int) -> str: """Return the sentence for a label used in only some units, with its consequence.""" return ( - f"used in {used} of {total} units: an analysis of {html.escape(label)} will be " - "refused for the other units" + f"used in {used} of {total} folders: an analysis of {html.escape(label)} will be " + "refused for the other folders" ) @@ -66,6 +66,8 @@ def __init__( on_change: Callable[[], None] = lambda: None, ) -> None: self.store, self.types, self._on_change = store, types, on_change + # False when the image type has a page of its own (the stepper): no type column here. + self.place_types = True self.layouts: Sequence[UnitLayout] = () self.labels: list[str] = [] self.thumbs: Thumbnails | None = None @@ -84,9 +86,9 @@ def __init__( "B. Tag each channel", [ ui.help_text( - "For every unit, say which label each channel is, looking at its picture. " + "For every folder, say which label each channel is, looking at its picture. " "The same label should be in the same column in every row; a label used in " - "only some units is shown in orange. Every channel and every image type " + "only some folders is shown in orange. Every channel and every image type " "must be answered." ), self.contrast, @@ -105,7 +107,8 @@ def __init__( def open(self, layouts: Sequence[UnitLayout], labels: list[str], thumbs: Thumbnails) -> None: """Show the grid for these units and labels (the confirmed label list of step A).""" self.layouts, self.labels, self.thumbs = layouts, labels, thumbs - self.types.open(layouts) + if self.place_types: + self.types.open(layouts) self.detail.children = [] self.refresh() ui.set_visible(self.widget, True) @@ -193,7 +196,8 @@ def refresh(self) -> None: self._building = True try: self.cells, self.confirm_buttons = {}, [] - self.types.open(self.layouts) + if self.place_types: + self.types.open(self.layouts) width = max((u.channels for u in self.layouts if not u.rgb), default=3) width = max(width, 3 if any(u.rgb for u in self.layouts) else 1) groups: dict[tuple, list[UnitLayout]] = {} @@ -201,9 +205,9 @@ def refresh(self) -> None: groups.setdefault(layout.signature if layout.usable else (0,), []).append(layout) blocks: list[widgets.Widget] = [] header = widgets.GridBox( - [widgets.HTML("unit")] + [widgets.HTML("folder")] + [widgets.HTML(f"channel {i + 1} (C{i})") for i in range(width)] - + [widgets.HTML("image type")], + + [widgets.HTML("image type" if self.place_types else "")], layout=columns(width), ) blocks.append(header) @@ -221,7 +225,7 @@ def _group(self, units: Sequence[UnitLayout], width: int) -> widgets.Widget: if first.usable: names = " · ".join(quoted(first.name(i)) for i in range(first.channels)) what = f"Header names: {names}" if not first.rgb else "Header says: colour (RGB)" - text = f"{what}  ·  {len(units)} unit(s) with this layout." + text = f"{what}  ·  {len(units)} folder(s) with this layout." proposal = self._proposal(first) pending = [u for u in units if self._pending(u)] if proposal is not None: @@ -233,10 +237,10 @@ def _group(self, units: Sequence[UnitLayout], width: int) -> widgets.Widget: out.append(widgets.HTML(f"
{text}
")) if pending: button = ui.button( - f" Yes, these are right for these {len(pending)} unit(s)", + f" Yes, these are right for these {len(pending)} folder(s)", style="success", icon="check", - tooltip="Save the pre-ticked answers of these units", + tooltip="Save the pre-ticked answers of these folders", ) button.on_click(lambda _, p=pending: self._confirm_layout(p)) self.confirm_buttons.append(button) @@ -269,7 +273,8 @@ def _row(self, layout: UnitLayout, width: int) -> widgets.Widget: cells.append(widgets.HTML(f"{text}")) else: cells.append(self._cell(layout, index, tags[index], index in proposed, shown)) - cells.append(self.types.cell(layout) if layout.usable else widgets.HTML("")) + typed = layout.usable and self.place_types + cells.append(self.types.cell(layout) if typed else widgets.HTML("")) return widgets.GridBox(cells, layout=columns(width)) def _picture(self, layout: UnitLayout, index: int) -> widgets.Widget: @@ -352,7 +357,7 @@ def _summary(self) -> None: for label, places in positions(self.layouts, answers.units, self.labels).items(): if len(places) > 1: where = ", ".join( - f"channel {i + 1} in {n} unit(s)" for i, n in sorted(places.items()) + f"channel {i + 1} in {n} folder(s)" for i, n in sorted(places.items()) ) lines.append( f"
{html.escape(label)} is not always in the same " @@ -373,9 +378,11 @@ def _usage_line(self, label: str, used: int, total: int) -> str: """Return the usage line of a label; one used in only some units is orange.""" mark = chip(label, self.labels, self._shown_as(label)) if used == total: - return f"
{mark} used in all {total} units
" + return f"
{mark} used in all {total} folders
" if used == 0: - return f"
{mark} not used in any unit yet
" + return ( + f"
{mark} not used in any folder yet
" + ) return ( "
{mark} " @@ -387,14 +394,14 @@ def _progress(self, total: int) -> str: answers = self.store.answers() left = remaining(self.layouts, answers.units, self.labels) if left.done: - return ui.status("ok", f"Every channel of every one of the {total} units is tagged.") + return ui.status("ok", f"Every channel of every one of the {total} folders is tagged.") parts = [] if left.cells: parts.append(f"{left.cells} channel(s) still to tag") if left.types: parts.append(f"{left.types} image type(s) still to choose") if left.blocked: - parts.append(f"{left.blocked} unit(s) cannot be tagged") + parts.append(f"{left.blocked} folder(s) cannot be tagged") return ui.status("warn", "Not finished: " + ", ".join(parts) + ".") diff --git a/src/morphobricks_preprocessing/notebook_channels_labels.py b/src/morphobricks_preprocessing/notebook_channels_labels.py index 9e45d095..abe020ea 100644 --- a/src/morphobricks_preprocessing/notebook_channels_labels.py +++ b/src/morphobricks_preprocessing/notebook_channels_labels.py @@ -41,7 +41,7 @@ EXPLANATION = ( "A label is the short name of what was stained, for example DNA or " "ACTIN, not the dye (AF488) or the microscope's slot (Ch1). The same " - "label must be used for the same stain in every unit, and different stains must never share " + "label must be used for the same stain in every folder, and different stains must never share " "a label. Letters and digits, no space at the start or end; capital letters count as the " "same label." ) @@ -112,7 +112,7 @@ def __init__(self, store: AnswerStore, on_change: Callable[[], None]) -> None: "number of channels found in any image; Add label adds more. Header " "names are offered only when they are valid labels as they are and not " "generic. " - "Every channel position of every unit needs a label, so an unused slot " + "Every channel position of every folder needs a label, so an unused slot " "needs one too: declare it here (for example unused)." ), self.note, diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py index af4bd11f..5fa649f8 100644 --- a/src/morphobricks_preprocessing/notebook_channels_type.py +++ b/src/morphobricks_preprocessing/notebook_channels_type.py @@ -11,6 +11,7 @@ from __future__ import annotations +import html from collections.abc import Callable, Sequence import ipywidgets as widgets @@ -19,7 +20,7 @@ from .channel_answers import synced, with_type from .channel_labels import UnitLayout from .draft_answers import UnitKey -from .notebook_channels_view import AnswerStore, blank_chip +from .notebook_channels_view import AnswerStore, blank_chip, quoted, unit_name OPTIONS = [("separate channels", "channels"), ("colour (RGB)", "rgb")] @@ -79,12 +80,12 @@ def _note(self, layout: UnitLayout) -> str: if saved == "channels" and layout.rgb: return ( "The header says colour (RGB): the " - "run refuses a unit declared otherwise." + "run refuses a folder declared otherwise." ) if saved == "rgb" and not layout.rgb: return ( "The header does not say colour: the " - "run refuses this unit unless its header does." + "run refuses this folder unless its header does." ) return "" @@ -127,4 +128,99 @@ def confirm_header(self, keys: Sequence[UnitKey]) -> bool: return ok -__all__ = ["OPTIONS", "ImageTypeColumn"] +class ImageTypeRows: + """The image type on a page of its own: one row per folder, its header and its choice. + + The cells are the :class:`ImageTypeColumn`'s own, so the answers, notes and refusals are + exactly those of the column in the tag grid; only the place differs. + """ + + def __init__(self, types: ImageTypeColumn) -> None: + self.types = types + self.rows = widgets.VBox() + self.status = widgets.HTML() + self.problem = widgets.HTML() + self.widget = ui.card( + "Image type of each folder", + [ + ui.help_text( + "Separate channels: one stain or marker per channel. Colour " + "(RGB): a colour picture whose red, green and blue are kept together. " + "Nothing is chosen for you; colour is pre-ticked only where the image " + "header says colour, and needs your click." + ), + self.rows, + self.problem, + self.status, + ], + visible=False, + ) + + def open(self, layouts: Sequence[UnitLayout]) -> None: + """Show one row per folder of these layouts.""" + self.types.open(layouts) + self.refresh() + ui.set_visible(self.widget, True) + + def close(self) -> None: + """Hide the rows.""" + ui.set_visible(self.widget, False) + + @property + def answered(self) -> int: + """Return how many usable folders have a saved image type.""" + return sum(1 for u in self.types.layouts if u.usable and self.types.saved(u.key)) + + def refresh(self) -> None: + """Rebuild the rows from the saved answers.""" + self.types.open(self.types.layouts) # a fresh set of cells, built from the answers + rows = [self._row(u) for u in sorted(self.types.layouts, key=lambda u: u.key)] + proposed = [u.key for u in self.types.layouts if self.types.proposed(u)] + if proposed: + button = ui.button( + f" Yes, colour (RGB) is right for these {len(proposed)} folder(s)", + style="success", + icon="check", + ) + button.on_click(lambda _, keys=tuple(proposed): self._confirm(keys)) + rows.append(button) + self.rows.children = rows + self.problem.value = self.types.message + usable = sum(1 for u in self.types.layouts if u.usable) + done = self.answered + self.status.value = ( + ui.status("ok", f"The image type of every one of the {usable} folders is answered.") + if done == usable + else ui.status("warn", f"Not finished: {usable - done} image type(s) to choose.") + ) + + def _confirm(self, keys: Sequence[UnitKey]) -> None: + self.types.confirm_header(keys) + + def _row(self, layout: UnitLayout) -> widgets.Widget: + """Build one folder's row: its name, what its header says, and its choice.""" + info = f"{html.escape(unit_name(layout.key))}" + if not layout.usable: + info += ui.status( + "warn", "Cannot be answered.", [html.escape(layout.problem)], raw=True + ) + return widgets.HTML(info) + said = ( + "header says: colour (RGB)" + if layout.rgb + else "header names: " + + " · ".join(quoted(layout.name(i)) for i in range(layout.channels)) + ) + info += f"
{said}
" + return widgets.GridBox( + [widgets.HTML(info), self.types.cell(layout)], + layout=widgets.Layout( + grid_template_columns="minmax(200px, 2fr) minmax(200px, 3fr)", + grid_gap="4px 12px", + width="99%", + margin="0 0 8px 0", + ), + ) + + +__all__ = ["OPTIONS", "ImageTypeColumn", "ImageTypeRows"] diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 4d943fa8..ff2f3938 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -79,7 +79,9 @@ def _build_check_save(self) -> None: self._check: CandidateCheck | None = None self.same_way: dict[str, ui.ConfirmToggle] = {} self.saved_line = widgets.HTML() - self.run_now = ui.button(" Run", style="success", icon="play", tooltip="Run step 3") + self.run_now = ui.button( + " Run", style="success", icon="play", tooltip="Compute the histograms (Run, step 12)" + ) self.change_saved = ui.button(" Change…", style="warning", icon="pencil") self.fold_bar = widgets.HBox([self.saved_line, self.run_now, self.change_saved]) ui.set_visible(self.fold_bar, False) @@ -206,7 +208,7 @@ def _todo(self, applied: object, answers: object, problem: str) -> tuple[list, l questions = list(missing_confirmations(applied, answers)) extra = [problem] if problem else [] left = masks_unanswered(answers, self._mask_types()) - extra += [f"mask {m}: separate objects or regions? (Your masks)" for m in left] + extra += [f"mask {m}: separate objects or regions? (Masks, step 10)" for m in left] if len(applied.conditions) > 1: extra += [ f"what {folder} is compared against (choose, or no comparison)" @@ -310,7 +312,7 @@ def _show_saved_state(self) -> None: state = self._published() if self._saved_current(): when = self._saved_on or (state.saved_on if state else "") - self.save_status.value = ui.status("ok", f"Saved {when}. Step 3 can run.") + self.save_status.value = ui.status("ok", f"Saved {when}. The run (step 12) can start.") self.saved_line.value = ui.status("ok", f"Saved on {when}, nothing changed.") else: self.saved_line.value = "" @@ -345,7 +347,9 @@ def _unfold(self) -> None: def _run_from_fold(self) -> None: if self.run() is None: self.saved_line.value += ui.status( - "warn", "Not run yet.", ["See step 3: its checklist says what is still to do."] + "warn", + "Not run yet.", + ["See Run (step 12): its checklist says what is still to do."], ) # Saving ------------------------------------------------------------------------------ @@ -363,7 +367,9 @@ def save_experiment(self) -> bool: return False if not self._confirm_ids(): self.save_status.value = ui.status( - "error", "Not saved: a short name was refused.", ["See Names for your experiment."] + "error", + "Not saved: a short name was refused.", + ["See Conditions and Replicates (steps 4 and 5)."], ) return False self._save_description() diff --git a/src/morphobricks_preprocessing/notebook_check_view.py b/src/morphobricks_preprocessing/notebook_check_view.py index 71ae5c2e..37c7d0d2 100644 --- a/src/morphobricks_preprocessing/notebook_check_view.py +++ b/src/morphobricks_preprocessing/notebook_check_view.py @@ -25,12 +25,12 @@ "settings). They are then one biological replicate, compared with each other." ) QUESTION_WORDS = { - "id_pending": "a short name is missing for {subject} (Names for your experiment)", - "repeat_not_confirmed": "{subject}: answer “same way?” below", - "confirmed_on_missing": "{subject}: answer “same way?” again below", - "unit_channels_missing": "the channels of {subject} are not tagged (Your channels)", - "unit_channel_untagged": "{subject} is not tagged (Your channels)", - "channel_undeclared": "the label {subject} is used but not declared (Your channels)", + "id_pending": "a short name is missing for {subject} (Conditions or Replicates)", + "repeat_not_confirmed": "{subject}: answer “same way?” above", + "confirmed_on_missing": "{subject}: answer “same way?” again above", + "unit_channels_missing": "the channels of {subject} are not tagged (Tag channels)", + "unit_channel_untagged": "{subject} is not tagged (Tag channels)", + "channel_undeclared": "the label {subject} is used but not declared (Stains)", "control_unresolved": "say what {subject} is compared against", "legacy_folder_missing": "{subject} is gone: confirm the removal", } @@ -116,7 +116,7 @@ def checks_html(check: CandidateCheck | None) -> str: return ui.status( "error", "The description would be refused:", _issue_lines(check.refusals), raw=True ) - parts = [ui.status("ok", "The strict reader accepts the exact bytes that will be saved.")] + parts: list[str] = [] hetero = check.heterogeneous if hetero is not None: parts.append( @@ -145,12 +145,14 @@ def checks_html(check: CandidateCheck | None) -> str: else: parts.append(ui.status("ok", "The images agree with it (headers checked).")) changes = check.changes or ("no row changes",) - parts.append( - "experiment_design.csv (written only when you save, from " + details = ( + ui.status("ok", "The strict reader accepts the exact bytes that will be saved.") + + "experiment_design.csv (written only when you save, from " "the description)
    " + "".join(f"
  • {html.escape(c)}
  • " for c in changes) + "
" ) + parts.append(f"
Details{details}
") return "".join(parts) diff --git a/src/morphobricks_preprocessing/notebook_description.py b/src/morphobricks_preprocessing/notebook_description.py index e83bbd58..35ff6cd2 100644 --- a/src/morphobricks_preprocessing/notebook_description.py +++ b/src/morphobricks_preprocessing/notebook_description.py @@ -36,21 +36,14 @@ from .draft_format import DraftFileError from .notebook_description_saved import _SavedDraft from .notebook_description_view import ( - ID_COLUMNS, KIND_NAMES, - KIND_TITLES, REFERENCE_COLUMNS, Entry, - duplicate_hint, header_row, id_state, - members_text, refusal_card, - same_name, - single_note, - text_box, - why_no_proposal, ) +from .notebook_names import _NameRows, _updated from .run import OutputLockedError from .run.draft_file import save_draft from .run.output_guard import OutputInsideMasksError @@ -65,7 +58,7 @@ ) -class _DescriptionCard(_SavedDraft): +class _DescriptionCard(_NameRows, _SavedDraft): """Step 1's names and references cards: the user's answers to the draft description.""" def _build_description_cards(self) -> None: @@ -88,6 +81,7 @@ def _build_description_cards(self) -> None: self._id_states: dict[tuple[str, str], widgets.HTML] = {} self.names_status = widgets.HTML() self.names_grid = widgets.VBox() + self.condition_rows, self.replicate_rows = widgets.VBox(), widgets.VBox() self.ids_confirmed = ui.ConfirmToggle( IDS_QUESTION, IDS_EXPLANATION, @@ -247,35 +241,6 @@ def _save_description(self) -> None: # The names card ---------------------------------------------------------------------- - def _entries(self) -> list[Entry]: - """Return the rows of the names card: conditions, repeats, technical-repeat folders.""" - draft, answers = self.draft, self.draft_answers - entries = [ - Entry( - "conditions", - folder, - slot, - f"{html.escape(folder)}", - answers.conditions.get(folder, ConditionAnswer()).display_name, - ) - for folder, slot in sorted(draft.conditions.items()) - ] - entries += [ - Entry( - "repeats", - label, - repeat.id, - f"{html.escape(label)}
{members_text(repeat.members)}", - answers.repeats.get(label, RepeatAnswer()).display_name, - ) - for label, repeat in sorted(draft.repeats.items()) - ] - entries += [ - Entry("technical_repeats", folder, slot, f"{html.escape(folder)}") - for folder, slot in sorted(draft.technical_repeats.items()) - ] - return entries - def _ids_all_confirmed(self) -> bool: return not any(q.code == "id_pending" for q in self.draft.open_questions()) @@ -297,55 +262,6 @@ def _refresh_description(self) -> None: ui.set_visible(self.references_card, len(self.draft.conditions) > 1) self._refresh_channels() - def _build_names(self) -> None: - """Rebuild the ID and shown-as boxes, keeping what was typed and not yet confirmed.""" - typed = {key: box.value for key, box in self.id_inputs.items()} - self.id_inputs, self.name_inputs, self._id_states = {}, {}, {} - self.name_hints = {} - entries = self._entries() - blocks: list[widgets.Widget] = [] - for kind, title in KIND_TITLES.items(): - shown = [e for e in entries if e.kind == kind and not e.fixed] - if not shown: - continue - asks_name = kind != "technical_repeats" - rows: list[widgets.Widget] = [ - header_row(title, "ID", "Shown as (optional)" if asks_name else "", "") - ] - for entry in shown: - rows += self._entry_row(entry, typed, asks_name=asks_name) - blocks.append(widgets.VBox(rows)) - self.single_note.value = single_note(entries) - self.names_grid.children = blocks - - def _entry_row(self, entry: Entry, typed: dict, *, asks_name: bool) -> list[widgets.Widget]: - """Build the grid row of one entry (and why it has no proposal, if so).""" - key = (entry.kind, entry.key) - confirmed = entry.slot.confirmed - text = confirmed or typed.get(key) or entry.slot.proposed or "" - box = text_box(text, "type an ID", disabled=confirmed is not None) - state = widgets.HTML(id_state(entry, text)) - box.observe( - lambda change, e=entry, s=state: self._id_typed(e, s, change["new"]), names="value" - ) - self.id_inputs[key], self._id_states[key] = box, state - cells: list[widgets.Widget] = [widgets.HTML(entry.where), box] - if asks_name: - name = text_box(entry.shown or "", "same as the ID") - name.observe(lambda change, e=entry: self._name_typed(e, change["new"]), names="value") - self.name_inputs[key] = name - cells.append(name) - else: - cells.append(widgets.HTML("")) - cells.append(state) - row: list[widgets.Widget] = [widgets.GridBox(cells, layout=ID_COLUMNS)] - if asks_name: - self.name_hints[key] = widgets.HTML() - row.append(self.name_hints[key]) - if why := why_no_proposal(entry): - row.append(widgets.HTML(why)) - return row - def _show_names_status(self) -> None: """Say where the answers are saved (or why not), after any note or refusal.""" if self._draft_saved: @@ -372,31 +288,6 @@ def _id_typed(self, entry: Entry, state: widgets.HTML, text: str) -> None: if entry.kind == "conditions" and not self._refreshing: self._relabel_references() - def _name_typed(self, entry: Entry, text: str) -> None: - """Save the name shown in plots and tables; an empty box means the ID is shown. - - The hint about a duplicate follows every keystroke; the answer is saved without - rebuilding the boxes, so typing is never interrupted. A duplicate is still saved: the - reader refuses it when the description is reviewed. - """ - if self._refreshing or self.draft is None: - return - self._update_name_hints(entry.kind) - self._draft_note = "" - answers = self.draft_answers - kind = "conditions" if entry.kind == "conditions" else "repeats" - new = _updated(answers, kind, entry.key, display_name=text or None) - if new != answers: - self._names_refusal = "" - self._answer(new, rebuild=False) - - def _update_name_hints(self, kind: str) -> None: - """Show a small orange hint beside every shown-as name that another one of its kind has.""" - boxes = {key: box.value for key, box in self.name_inputs.items() if key[0] == kind} - for key, text in boxes.items(): - same = [k for k, other in boxes.items() if k != key and same_name(text, other)] - self.name_hints[key].value = duplicate_hint(kind, text, [k[1] for k in same]) - def _ids_toggled(self, confirmed: bool) -> None: if self.draft is None: return @@ -544,18 +435,6 @@ def _sync_controls(self) -> None: check.value = condition in referenced -def _updated(answers: DraftAnswers, kind: str, key: str, **fields: str | None) -> DraftAnswers: - """Return the answers with fields of one condition or repeat set; an empty entry is dropped.""" - table = dict(getattr(answers, kind)) - empty = ConditionAnswer() if kind == "conditions" else RepeatAnswer() - entry = replace(table.get(key, empty), **fields) - if entry == empty: - table.pop(key, None) - else: - table[key] = entry - return replace(answers, **{kind: table}) - - def _with_ids(answers: DraftAnswers, ids: dict[tuple[str, str], str]) -> DraftAnswers: """Return the answers with exactly these IDs (any ID not in ``ids`` is taken back).""" conditions = { diff --git a/src/morphobricks_preprocessing/notebook_mask_question.py b/src/morphobricks_preprocessing/notebook_mask_question.py index b29c418e..bff4b9fd 100644 --- a/src/morphobricks_preprocessing/notebook_mask_question.py +++ b/src/morphobricks_preprocessing/notebook_mask_question.py @@ -144,7 +144,7 @@ def _build_made_from(self, labels: list[str], chosen: tuple[str, ...]) -> None: ) box.observe(lambda _: self._made_from_changed(), names="value") self.made_from[label] = box - empty = "no channel labels yet: declare them on *Your channels*" + empty = "no channel labels yet: declare them on Stains (step 8)" hint = "answer the question first" if labels and self.saved is None else "" tail = [ widgets.HTML(f"{hint or (empty if not labels else '')}") diff --git a/src/morphobricks_preprocessing/notebook_masks_screen.py b/src/morphobricks_preprocessing/notebook_masks_screen.py index 30b8f962..a22c93e7 100644 --- a/src/morphobricks_preprocessing/notebook_masks_screen.py +++ b/src/morphobricks_preprocessing/notebook_masks_screen.py @@ -42,7 +42,7 @@ "has its own number, as nuclei or cells from a segmentation) or regions (inside or " "outside, as a tissue area). Nothing is chosen for you: the pixel type and the number of " "values are shown only to help you answer. The mask ID is the folder name when it can be " - "one; it is used everywhere after, also by Describe the masks in step 3." + "one; it is used everywhere after, also by Describe the masks (step 13)." ) diff --git a/src/morphobricks_preprocessing/notebook_names.py b/src/morphobricks_preprocessing/notebook_names.py new file mode 100644 index 00000000..c4f7cec6 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_names.py @@ -0,0 +1,156 @@ +"""The rows of the names card: an ID and a shown-as name per condition, repeat and folder. + +Moved out of :mod:`.notebook_description` so the stepper can place the rows on two steps: the +condition rows on *Conditions* (``condition_rows``) and the replicate and technical-repeat rows +on *Replicates*, beside the pairing (``replicate_rows``). The same row widgets are also the +children of the names card's grid (``names_grid``), which embedding pages show as before. +""" + +from __future__ import annotations + +import html +from dataclasses import replace + +import ipywidgets as widgets + +from .draft_answers import ConditionAnswer, DraftAnswers, RepeatAnswer +from .notebook_description_view import ( + ID_COLUMNS, + KIND_TITLES, + Entry, + duplicate_hint, + header_row, + id_state, + members_text, + same_name, + single_note, + text_box, + why_no_proposal, +) + + +class _NameRows: + """The ID and shown-as rows of the names card (a mixin of the description card).""" + + def _entries(self) -> list[Entry]: + """Return the rows of the names card: conditions, repeats, technical-repeat folders.""" + draft, answers = self.draft, self.draft_answers + entries = [ + Entry( + "conditions", + folder, + slot, + f"{html.escape(folder)}", + answers.conditions.get(folder, ConditionAnswer()).display_name, + ) + for folder, slot in sorted(draft.conditions.items()) + ] + entries += [ + Entry( + "repeats", + label, + repeat.id, + f"{html.escape(label)}
{members_text(repeat.members)}", + answers.repeats.get(label, RepeatAnswer()).display_name, + ) + for label, repeat in sorted(draft.repeats.items()) + ] + entries += [ + Entry("technical_repeats", folder, slot, f"{html.escape(folder)}") + for folder, slot in sorted(draft.technical_repeats.items()) + ] + return entries + + def _build_names(self) -> None: + """Rebuild the ID and shown-as boxes, keeping what was typed and not yet confirmed.""" + typed = {key: box.value for key, box in self.id_inputs.items()} + self.id_inputs, self.name_inputs, self._id_states = {}, {}, {} + self.name_hints = {} + entries = self._entries() + blocks: list[widgets.Widget] = [] + kinds: list[str] = [] + for kind, title in KIND_TITLES.items(): + shown = [e for e in entries if e.kind == kind and not e.fixed] + if not shown: + continue + asks_name = kind != "technical_repeats" + rows: list[widgets.Widget] = [ + header_row(title, "ID", "Shown as (optional)" if asks_name else "", "") + ] + for entry in shown: + rows += self._entry_row(entry, typed, asks_name=asks_name) + blocks.append(widgets.VBox(rows)) + kinds.append(kind) + self.single_note.value = single_note(entries) + self.names_grid.children = blocks + pairs = list(zip(kinds, blocks, strict=True)) + self.condition_rows.children = [b for k, b in pairs if k == "conditions"] + self.replicate_rows.children = [b for k, b in pairs if k != "conditions"] + + def _entry_row(self, entry: Entry, typed: dict, *, asks_name: bool) -> list[widgets.Widget]: + """Build the grid row of one entry (and why it has no proposal, if so).""" + key = (entry.kind, entry.key) + confirmed = entry.slot.confirmed + text = confirmed or typed.get(key) or entry.slot.proposed or "" + box = text_box(text, "type an ID", disabled=confirmed is not None) + state = widgets.HTML(id_state(entry, text)) + box.observe( + lambda change, e=entry, s=state: self._id_typed(e, s, change["new"]), names="value" + ) + self.id_inputs[key], self._id_states[key] = box, state + cells: list[widgets.Widget] = [widgets.HTML(entry.where), box] + if asks_name: + name = text_box(entry.shown or "", "same as the ID") + name.observe(lambda change, e=entry: self._name_typed(e, change["new"]), names="value") + self.name_inputs[key] = name + cells.append(name) + else: + cells.append(widgets.HTML("")) + cells.append(state) + row: list[widgets.Widget] = [widgets.GridBox(cells, layout=ID_COLUMNS)] + if asks_name: + self.name_hints[key] = widgets.HTML() + row.append(self.name_hints[key]) + if why := why_no_proposal(entry): + row.append(widgets.HTML(why)) + return row + + def _name_typed(self, entry: Entry, text: str) -> None: + """Save the name shown in plots and tables; an empty box means the ID is shown. + + The hint about a duplicate follows every keystroke; the answer is saved without + rebuilding the boxes, so typing is never interrupted. A duplicate is still saved: the + reader refuses it when the description is reviewed. + """ + if self._refreshing or self.draft is None: + return + self._update_name_hints(entry.kind) + self._draft_note = "" + answers = self.draft_answers + kind = "conditions" if entry.kind == "conditions" else "repeats" + new = _updated(answers, kind, entry.key, display_name=text or None) + if new != answers: + self._names_refusal = "" + self._answer(new, rebuild=False) + + def _update_name_hints(self, kind: str) -> None: + """Show a small orange hint beside every shown-as name that another one of its kind has.""" + boxes = {key: box.value for key, box in self.name_inputs.items() if key[0] == kind} + for key, text in boxes.items(): + same = [k for k, other in boxes.items() if k != key and same_name(text, other)] + self.name_hints[key].value = duplicate_hint(kind, text, [k[1] for k in same]) + + +def _updated(answers: DraftAnswers, kind: str, key: str, **fields: str | None) -> DraftAnswers: + """Return the answers with fields of one condition or repeat set; an empty entry is dropped.""" + table = dict(getattr(answers, kind)) + empty = ConditionAnswer() if kind == "conditions" else RepeatAnswer() + entry = replace(table.get(key, empty), **fields) + if entry == empty: + table.pop(key, None) + else: + table[key] = entry + return replace(answers, **{kind: table}) + + +__all__ = ["_NameRows", "_updated"] diff --git a/src/morphobricks_preprocessing/notebook_plots.py b/src/morphobricks_preprocessing/notebook_plots.py index 82c994d9..fe5d00f2 100644 --- a/src/morphobricks_preprocessing/notebook_plots.py +++ b/src/morphobricks_preprocessing/notebook_plots.py @@ -287,12 +287,20 @@ def _draw(self) -> None: self.boxes.value = self.curves.value = b"" return channel = self.channel.value or 0 - boxes = repeat_boxes(report, channel) labels = self.labels + boxes = sorted( + repeat_boxes(report, channel), + key=lambda box: labels.order(box.condition, box.biological_repeat), + ) self.boxes.value = ( intensity_boxes_png(boxes, report.high_percentile, channel, labels) or b"" ) - curves = repeat_distributions(self.records, channel) + curves = { + condition: dict( + sorted(found.items(), key=lambda item: labels.order(condition, item[0])) + ) + for condition, found in repeat_distributions(self.records, channel).items() + } shifted = report.shifted_repeats(channel) self.curves.value = repeat_curves_png(curves, shifted, channel, labels) or b"" diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py new file mode 100644 index 00000000..15406b5e --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_step_states.py @@ -0,0 +1,206 @@ +"""What each step of the stepper says about itself: done or not, and one line. + +Every line is read from the state the cards already keep (the folder check, the confirmed axis +roles, the draft answers, the channel and mask cards, the published file, the run); nothing is +checked or computed again here, and nothing is changed. A mixin of +:class:`.notebook_ui.PreprocessingNotebook`, used by :mod:`.notebook_steps`. +""" + +from __future__ import annotations + +from .channel_labels import remaining +from .mask_answers import mask_kinds, masks_unanswered +from .notebook_stepper import StepState +from .notebook_values import size_rows +from .validation import plural + +ANSWER_STEPS = range(3, 11) +"""The steps (0-based) whose answers the published file holds: conditions to check and save.""" + + +def _ids(wanted: dict[tuple[str, str], str], kind: str) -> list[str]: + return [text for (k, _), text in sorted(wanted.items()) if k == kind] + + +class _StepStates: + """The :class:`.notebook_stepper.StepState` of each step, from the cards' state.""" + + def _step_states(self) -> list: + """Return the state callables of the 13 steps, in order.""" + return [ + self._images_state, + self._axes_state, + self._sizes_state, + self._conditions_state, + self._replicates_state, + self._references_state, + self._image_type_state, + self._stains_state, + self._tags_state, + self._masks_state, + self._saved_state, + self._run_state, + self._describe_state, + ] + + def _reopen_changed(self) -> bool: + """Return True when a published file exists and the folders changed since it was saved.""" + state = self._published() if self.preflight is not None else None + return state is not None and state.changed + + def _images_state(self) -> StepState: + pre = self.preflight + if pre is None: + return StepState(False, "not checked yet") + text = ( + f"{plural(len(pre.discovery.images), 'image')} · " + f"{plural(len(pre.discovery.leaves), 'folder')}" + ) + types = self._mask_types() + if types: + text += " · masks " + ", ".join(types) + state = self._published() + if state is not None and state.changed: + rec = state.reconciliation + text += f" · since saved: {len(rec.new)} new, {len(rec.removed)} gone" + if not pre.ok: + text += " · problems to fix" + return StepState(pre.ok, text) + + def _axes_state(self) -> StepState: + mapping = self.confirmed_mapping + if mapping is None: + return StepState(False, "not confirmed") + return StepState(True, ", ".join(role for role in mapping.roles if role) + " confirmed") + + def _sizes_state(self) -> StepState: + if self.preflight is None or not self.preflight.metadata.files: + return StepState(False, "after the folder check") + key = (id(self.preflight), self.confirmed_mapping, self.missing_as_singleton.value) + if getattr(self, "_sizes_key", None) != key: # the card's own sizes, read once + shown, roles = self._shown_calibration() + self._sizes_key, self._sizes_rows = key, size_rows(shown.calibration.sizes, roles) + rows = self._sizes_rows + if len(rows) == 1: + x, y = rows[0][0][:2] + return StepState(True, f"X {x} · Y {y}") + return StepState(True, f"{len(rows)} different sets of sizes") + + def _changed(self, index: int, done: bool) -> bool: + return not done and index in ANSWER_STEPS and self._reopen_changed() + + def _conditions_state(self) -> StepState: + if self.draft is None: + return StepState(False, "after the folder check") + wanted = self._wanted_ids() + ids = _ids(wanted, "conditions") + shown = { + wanted.get(("conditions", folder), ""): answer.display_name + for folder, answer in self.draft_answers.conditions.items() + if answer.display_name + } + text = ", ".join(f"{i} ({shown[i]})" if i in shown else i or "?" for i in ids) + done = all(ids) + return StepState(done, text or "none", self._changed(3, done)) + + def _replicates_state(self) -> StepState: + if self.draft is None: + return StepState(False, "after the folder check") + wanted = self._wanted_ids() + repeats, technical = _ids(wanted, "repeats"), _ids(wanted, "technical_repeats") + text = ", ".join(i or "?" for i in repeats) + paired = [ + f"{condition}/{folder} = {wanted.get(('repeats', group)) or group}" + for (condition, folder), group in sorted(self.draft_answers.pairing.items()) + ] + if paired: + text += f" ({'; '.join(paired)})" + if [i for i in technical if i != "single"]: + text += " · technical: " + ", ".join(i or "?" for i in technical) + done = all(repeats) and all(technical) + return StepState(done, text or "none", self._changed(4, done)) + + def _references_state(self) -> StepState: + if self.draft is None: + return StepState(False, "after the folder check") + conditions = sorted(self.draft.conditions) + if len(conditions) < 2: + return StepState(True, "one condition: nothing to compare") + chosen = {c: self._chosen_reference(c) for c in conditions} + text = " · ".join(f"{c} → {'?' if t is None else t or 'none'}" for c, t in chosen.items()) + done = None not in chosen.values() + return StepState(done, text, self._changed(5, done)) + + def _image_type_state(self) -> StepState: + if self._channels_key is None: + return StepState(False, "after the axes") + types = self.channel_types + usable = [u for u in self._channel_layouts if u.usable] + saved = [types.saved(u.key) for u in usable] + parts = [ + f"{word} ({plural(saved.count(value), 'folder')})" + for word, value in (("separate channels", "channels"), ("colour", "rgb")) + if value in saved + ] + done = None not in saved + if not done: + parts.append(f"{saved.count(None)} to choose") + return StepState(done, ", ".join(parts), self._changed(6, done)) + + def _stains_state(self) -> StepState: + if self._channels_key is None: + return StepState(False, "after the axes") + done = self.channel_labels.confirmed + text = ", ".join(self.channel_labels.labels) if done else "not confirmed" + return StepState(done, text, self._changed(7, done)) + + def _tags_state(self) -> StepState: + if self._channels_key is None or not self.channel_labels.confirmed: + return StepState(False, "after the stains") + layouts = self._channel_layouts + left = remaining(layouts, self.draft_answers.units, self.channel_labels.labels) + done = not left.cells and not left.blocked + text = ( + f"every channel of {plural(len(layouts), 'folder')} tagged" + if done + else f"{left.cells} channel(s) still to tag" + ) + return StepState(done, text, self._changed(8, done)) + + def _masks_state(self) -> StepState: + types = self._mask_types() + if not types: + return StepState(self.preflight is not None, "no masks") + if self.draft_answers is None: + return StepState(False, "after the folder check") + kinds = mask_kinds(self.draft_answers) + text = " · ".join(f"{t} = {kinds.get(t, '?')}" for t in types) + done = not masks_unanswered(self.draft_answers, types) + return StepState(done, text, self._changed(9, done)) + + def _saved_state(self) -> StepState: + if self.draft is None: + return StepState(False, "after the folder check") + if self._saved_current(): + state = self._published() + return StepState(True, f"saved {self._saved_on or state.saved_on}") + return StepState(False, "not saved yet", self._changed(10, False)) + + def _run_state(self) -> StepState: + result = self.result + if result is None: + return StepState(False, "not run yet") + if not result.written: + return StepState(False, "stopped: see the messages") + warnings = [i for i in result.issues if not i.is_error] + return StepState( + True, f"{plural(result.plane_count, 'plane')} · {plural(len(warnings), 'warning')}" + ) + + def _describe_state(self) -> StepState: + if self.mask_step_result.value: + return StepState(True, "masks described") + return StepState(False, "optional") + + +__all__ = ["ANSWER_STEPS", "_StepStates"] diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py new file mode 100644 index 00000000..b2a077e3 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_stepper.py @@ -0,0 +1,196 @@ +"""The notebook's stepper: one step on screen at a time, a strip of chips, Back and Next. + +A layout only (``ui`` layer, owner decision 2026-11-09, UX proposal Q1). Each step is a page of +cards that already exist; the stepper shows one page and hides the others with +:func:`.notebook_style.set_visible`, so a hidden page keeps every widget, answer and picture as +it is. The strip has one chip per step: green with a check mark when the step is done, blue for +the step on screen, grey for a step still to do, and orange for a step a reopened experiment +changed. A chip is a button that opens its step. **Next** is never blocked: what must be answered +is still refused where it was (the save and the run say what is missing). + +What a step says about itself (done or not, a one-line summary) comes from a callable the owner +gives, which reads the cards' own state; the stepper decides nothing. The same lines make the +overview (:meth:`Stepper.overview`), each with a *change* button that opens its step. +""" + +from __future__ import annotations + +import html +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +import ipywidgets as widgets + +from . import notebook_style as ui + +CHIP_CLASSES = ("mb-chip-done", "mb-chip-current", "mb-chip-todo", "mb-chip-changed") +STEPPER_CSS = """ + +""" + + +@dataclass(frozen=True) +class StepState: + """What a step says about itself. + + Attributes + ---------- + done + True when the step needs nothing more. + text + One line saying what was answered or found (plain text). + changed + True when a reopened experiment changed what the step holds (orange). + """ + + done: bool + text: str + changed: bool = False + + +def _no_wait() -> str: + return "" + + +@dataclass(frozen=True) +class Step: + """One step: its chip title, its page and what it says about itself. + + Attributes + ---------- + title + The short name on the chip and in the page title. + purpose + The sentence under the page title (HTML). + children + The page's cards, in order. + state + Returns the step's :class:`StepState` from the cards' current state. + waiting + Returns why the step cannot be answered yet (HTML), or an empty string. + """ + + title: str + purpose: str + children: Sequence[widgets.Widget] + state: Callable[[], StepState] + waiting: Callable[[], str] = _no_wait + + +class Stepper: + """The strip, the pages and Back/Next of a list of steps; one page shown at a time.""" + + def __init__( + self, steps: Sequence[Step], on_show: Callable[[int], None] = lambda _: None + ) -> None: + self.steps = list(steps) + self._on_show = on_show + self.current = 0 + count = len(self.steps) + self.chips = [self._chip(i) for i in range(count)] + self.waits = [widgets.HTML() for _ in self.steps] + self.pages = [ + ui.page(f"Step {i + 1} of {count} · {step.title}", step.purpose, [wait, *step.children]) + for i, (step, wait) in enumerate(zip(self.steps, self.waits, strict=True)) + ] + self.back = ui.button("Back", icon="arrow-left") + self.next = ui.button("Next", style="primary", icon="arrow-right") + self.back.on_click(lambda _: self.go(self.current - 1)) + self.next.on_click(lambda _: self.go(self.current + 1)) + self.strip = widgets.HBox(self.chips) + self.strip.add_class("mb-strip") + nav = widgets.HBox([self.back, self.next]) + nav.add_class("mb-nav") + self._overview_lines: list[tuple[int, widgets.HTML]] = [] + self.widget = widgets.VBox( + [widgets.HTML(STEPPER_CSS), self.strip, *self.pages, nav], + layout=widgets.Layout(width="99%"), + ) + self.go(0) + + def _chip(self, index: int) -> widgets.Button: + chip = widgets.Button(layout=widgets.Layout(width="auto")) + chip.add_class("mb-chip") + chip.on_click(lambda _: self.go(index)) + return chip + + def go(self, index: int) -> None: + """Show step ``index`` (0-based; clamped to the steps there are).""" + self.current = max(0, min(index, len(self.steps) - 1)) + for i, page in enumerate(self.pages): + ui.set_visible(page, i == self.current) + self._on_show(self.current) + self.refresh() + + def states(self) -> list[StepState]: + """Return what every step says about itself now.""" + return [step.state() for step in self.steps] + + def refresh(self) -> None: + """Update the chips, the waiting line of the step shown, Back/Next and the overview.""" + states = self.states() + for i, (chip, step, state) in enumerate(zip(self.chips, self.steps, states, strict=True)): + kind = ( + "current" + if i == self.current + else "changed" + if state.changed + else "done" + if state.done + else "todo" + ) + for name in CHIP_CLASSES: + chip.remove_class(name) + chip.add_class(f"mb-chip-{kind}") + chip.description = f"{'✓ ' if state.done else ''}{i + 1} {step.title}" + chip.tooltip = state.text + self.waits[self.current].value = self.steps[self.current].waiting() + last = len(self.steps) - 1 + ui.set_visible(self.back, self.current > 0) + ui.set_visible(self.next, self.current < last) + if self.current > 0: + self.back.description = f" Back: {self.steps[self.current - 1].title}" + if self.current < last: + self.next.description = f" Next: {self.steps[self.current + 1].title}" + for index, line in self._overview_lines: + line.value = overview_line(self.steps[index].title, states[index]) + + def overview(self, count: int) -> widgets.Widget: + """Return the one-line summaries of the first ``count`` steps, each with *change*.""" + rows = [] + for index in range(count): + line = widgets.HTML() + change = ui.button("change", tooltip=f"Open step {index + 1}") + change.add_class("mb-change") + change.on_click(lambda _, i=index: self.go(i)) + self._overview_lines.append((index, line)) + rows.append(widgets.HBox([line, change], layout=widgets.Layout(align_items="center"))) + box = ui.card("Your experiment, step by step", rows) + box.add_class("mb-overview") + self.refresh() + return box + + +def overview_line(title: str, state: StepState) -> str: + """Return one overview line: a mark, the step's name and its summary (HTML).""" + mark, colour = ( + ("●", "#EA580C") if state.changed else ("✓", "#16A34A") if state.done else ("○", "#64748B") + ) + return ( + f"{mark} " + f"{html.escape(title)} · {html.escape(state.text)}" + ) + + +__all__ = ["CHIP_CLASSES", "STEPPER_CSS", "Step", "StepState", "Stepper", "overview_line"] diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py new file mode 100644 index 00000000..13b49480 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -0,0 +1,273 @@ +"""The shipped notebook's 13 steps, one on screen at a time (owner decision 2026-11-09). + +Pure composition of the cards the notebook already has (UX proposal 2026-11-09, PR 17a): the +order is images found, axes, pixel sizes, conditions, replicates, compared against, image type, +stains, tag channels, masks, check and save (with the overview of steps 1-10), run, and describe +the masks. Each answer is still saved, refused and checked by the card that asks it; the stepper +(:mod:`.notebook_stepper`) only shows one page and says, from each card's state +(:mod:`.notebook_step_states`), which steps are done. + +Three things follow the layout and nothing else: the tag grid leaves the image type to its own +page (:class:`.notebook_channels_type.ImageTypeRows`, the same answers), the condition names and +the replicate names sit on their own steps (:mod:`.notebook_names`), and the consistency card +follows the pairing, shown only when it warns. A page holding a card the saved reopening folded +unfolds it when it is opened. ``folder_step`` and ``axis_step`` are unchanged for embedders. +""" + +from __future__ import annotations + +import ipywidgets as widgets + +from . import notebook_style as ui +from .dataset import CONSISTENCY_CODES +from .notebook_channels import INTRO as CHANNELS_INTRO +from .notebook_channels_type import ImageTypeRows +from .notebook_check_save import INTRO as CHECK_INTRO +from .notebook_masks_screen import INTRO as MASKS_INTRO +from .notebook_step_states import _StepStates +from .notebook_stepper import Step, Stepper +from .run import Preflight + +FOLDER_INTRO = ( + "What happens here: the folders are scanned, the labels are taken from the folder " + "names (condition / biological repeat / technical repeat) and every image header is " + "checked. Problems are listed with what to do. Nothing in the folder is changed." +) +AXES_INTRO = ( + "Why this matters: every image is cut into flat 2D planes, one per depth slice, " + "channel and time point. The roles below are suggested from the image metadata, which is " + "not always right, so check them with the preview before confirming. The pictures of the " + "later steps are read only through the roles you confirm." +) +CONDITIONS_HELP = ( + "Each condition gets a short name (its ID), which later bricks use and which never changes " + "once the experiment description is saved. The folder name is proposed when it can be used " + "as it is; otherwise type one. The shown as name is only for plots and tables." +) +REPLICATES_HELP = ( + "Each biological replicate, and each technical-repeat folder, gets a short name (its ID), " + "which never changes once the experiment description is saved. A replicate pairs one folder " + "per condition (above); its name is proposed from the folder name when it can be used." +) +OVERVIEW_STEPS = 10 +"""The overview on *Check and save* restates steps 1 to 10.""" +UNFOLDING = {4: "design_card", 5: "references_card", 9: "masks_box"} +"""The steps (0-based) that show a card the saved reopening folds.""" + + +class _StepsFlow(_StepStates): + """``steps()``: the stepper of the shipped notebook, and the hooks that keep it current.""" + + def steps(self) -> widgets.Widget: + """Return the notebook's 13 steps, one shown at a time, with a strip and Back/Next. + + The shipped notebook shows this (with ``describe=True``) instead of the stacked + pages. Every answer is given and saved on the card that asks it, as on the pages; + Next is never blocked, and the save and the run still refuse what is missing. + + Returns + ------- + ipywidgets.Widget + The stepper; calling again returns the same one. + """ + if getattr(self, "_stepper", None) is not None: + return self._stepper.widget + self.channel_grid.place_types = False # the image type has its own step + if ui.is_visible(self.channel_grid.widget): + self.channel_grid.refresh() + for box in (self.folder_next, self.axis_next): + ui.set_visible(box, False) # Next and the strip say where to go + self.type_rows = ImageTypeRows(self.channel_types) + self.overview_box = widgets.VBox() + self.consistency_slot = widgets.VBox([self.consistency_card]) + self._stepper = Stepper(self._step_list(), self._step_shown) + self.overview_box.children = [self._stepper.overview(OVERVIEW_STEPS)] + self._refresh_type_rows() + self._refresh_steps() + return self._stepper.widget + + def _step_list(self) -> list[Step]: + """Return the 13 steps: titles, purposes, the existing cards, and their states.""" + states = self._step_states() + pages = [ + ("Images found", "Choose the folder with your images and check it.", + self._images_page()), + ("Axes", "Say which image dimension is width, height, channel and time.", + [ui.intro(AXES_INTRO), self.axis_box]), + ("Pixel sizes", "The size of one pixel, and where it comes from.", + [self.calibration_card]), + ("Conditions", "A short name for each condition.", [self._conditions_card()]), + ("Replicates", "Which folders are one replicate, and their names.", + [self.design_card, self._replicates_card(), self.consistency_slot]), + ("Compared against", "One question per condition.", [self.references_card]), + ("Image type", "Separate channels or a colour (RGB) picture, per folder.", + [self.type_rows.widget]), + ("Stains", "Name what you stained.", + [ui.intro(CHANNELS_INTRO), self.channel_labels.widget]), + ("Tag channels", "Say which stain each channel is, looking at its picture.", + [self.channel_grid.widget]), + ("Masks", "Say what each mask type is.", [ui.intro(MASKS_INTRO), self.masks_box]), + ("Check and save", "Check your experiment, confirm each replicate, then save.", + [self.overview_box, ui.intro(CHECK_INTRO), self.fold_bar, self.check_waiting, + self.check_card]), + ("Run", "Read the images one plane at a time and write the histogram table.", + self._run_page()), + ("Describe the masks", "Optional: how the mask types relate.", + [self.mask_step_card]), + ] # fmt: skip + waits = self._step_waits() + return [ + Step(title, purpose, children, state, waits.get(i, _nothing)) + for i, ((title, purpose, children), state) in enumerate(zip(pages, states, strict=True)) + ] + + def _images_page(self) -> list[widgets.Widget]: + """Return step 1's cards: the folders, the check and what it found.""" + return [ + ui.intro(FOLDER_INTRO), + self.folder_card, + self.output_card, + self.masks_input_card, + self.exclude_box, + self.check_button, + self.folder_status, + self.folder_lock.card, + self.check_report.widget, + self.folder_tiles, + self.labels_card, + self.masks_card, + self.issues_card, + ] + + def _run_page(self) -> list[widgets.Widget]: + """Return the run's cards (the mask step has its own step after it).""" + return [ + self.readiness_card, + self.settings_card, + self.plan_card, + self.run_button, + self.progress, + self.run_status, + self.run_lock.card, + self.run_report.widget, + self.run_tiles, + self.run_issues, + self.run_checks.card, + self.details_box, + ] + + def _conditions_card(self) -> widgets.Widget: + return ui.card("Conditions", [ui.help_text(CONDITIONS_HELP), self.condition_rows]) + + def _replicates_card(self) -> widgets.Widget: + return ui.card( + "Replicate and technical-repeat names", + [ui.help_text(REPLICATES_HELP), self.names_status, self.replicate_rows, + self.single_note], + ) # fmt: skip + + def _step_waits(self) -> dict: + """Return, by step (0-based), why it cannot be answered yet (HTML, empty when it can).""" + after_check = self._wait_for_check + return { + 2: lambda: "" if self.preflight is not None else after_check(), + 3: after_check, + 4: after_check, + 5: self._wait_for_references, + 6: self._wait_for_channels, + 7: self._wait_for_channels, + 8: self._wait_for_tags, + 12: self._wait_for_mask_step, + } + + def _wait_for_check(self) -> str: + if self.draft is not None: + return "" + return ui.status("info", "Waiting for step 1.", ["Check the experiment folder first."]) + + def _wait_for_references(self) -> str: + if self.draft is not None and len(self.draft.conditions) < 2: + return ui.status("ok", "There is one condition: nothing to compare.") + return self._wait_for_check() + + def _wait_for_channels(self) -> str: + return "" if self._channels_key is not None else self.channels_waiting.value + + def _wait_for_tags(self) -> str: + waiting = self._wait_for_channels() + if waiting or self.channel_labels.confirmed: + return waiting + return ui.status("info", "Waiting for the stains.", ["Confirm the stains in step 8 first."]) + + def _wait_for_mask_step(self) -> str: + if ui.is_visible(self.mask_step_card): + return "" + return ui.status("info", "Not offered.", ["The output folder is not shown."]) + + # Hooks ------------------------------------------------------------------------------- + + def _step_shown(self, index: int) -> None: + """Unfold the saved reopening when a step shows one of the cards it folds.""" + if index in UNFOLDING and getattr(self, "_folded", False): + self._unfold() + + def _refresh_steps(self) -> None: + """Update the strip and the consistency card's place (shown only when it warns).""" + stepper = getattr(self, "_stepper", None) + if stepper is None: + return + warns = self.preflight is not None and any( + i.code in CONSISTENCY_CODES for i in self._step1_issues() + ) + ui.set_visible(self.consistency_slot, warns) + stepper.refresh() + + def _refresh_run_step(self) -> None: + """Update the run step, then the strip (every answer ends here).""" + super()._refresh_run_step() + self._refresh_steps() + + def check_folder(self) -> Preflight | None: + """Check the folder; a reopened experiment opens on its first step still to do.""" + pre = super().check_folder() + stepper = getattr(self, "_stepper", None) + if stepper is not None and pre is not None and self._published() is not None: + states = stepper.states() + first = next( + (i for i, s in enumerate(states[: OVERVIEW_STEPS + 1]) if not s.done), + OVERVIEW_STEPS + 1, + ) + stepper.go(first) + return pre + + def _refresh_type_rows(self) -> None: + """Open the image-type step for the folders the channel step opened, or close it.""" + rows = getattr(self, "type_rows", None) + if rows is None: + return + if self._channels_key is None: + rows.close() + elif rows.types.layouts is not self._channel_layouts or not ui.is_visible(rows.widget): + rows.open(self._channel_layouts) + else: + rows.refresh() + + def _refresh_channels(self) -> None: + super()._refresh_channels() + self._refresh_type_rows() + + def _hide_channels(self, why: str = "") -> None: + super()._hide_channels(why) + self._refresh_type_rows() + + def _types_changed(self) -> None: + super()._types_changed() + self._refresh_type_rows() + self._refresh_steps() + + +def _nothing() -> str: + return "" + + +__all__ = ["_StepsFlow"] diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index f842374e..22d03aac 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -94,6 +94,7 @@ from .notebook_results import _ResultsStep from .notebook_run import _RunStep from .notebook_run_plan import _RunPlan +from .notebook_steps import _StepsFlow from .notebook_values import display_range from .planes import PlaneLayout from .run import Preflight, RunResult @@ -124,6 +125,7 @@ class PreprocessingNotebook( + _StepsFlow, _DescribedRun, _CheckSaveScreen, _FolderStep, @@ -374,9 +376,10 @@ def axis_step( "Why this matters: every image is cut into flat 2D planes, one per " "depth slice, channel and time point. The roles below are suggested from " "the image metadata, which is not always right, so check them with the " - "preview before confirming. Then confirm whether the images are fluorescence " - "or RGB colour images." + "preview before confirming." ) + if not self._describe: # a described run asks the image type per folder later + intro += " Then confirm whether the images are fluorescence or RGB colour images." return ui.page( "Step 2 · Review the axis roles" if title is None else title, "Tell the brick which image dimension is width, height, depth, channel and time." diff --git a/src/morphobricks_preprocessing/run_labels.py b/src/morphobricks_preprocessing/run_labels.py index 47a41de6..a03aadcd 100644 --- a/src/morphobricks_preprocessing/run_labels.py +++ b/src/morphobricks_preprocessing/run_labels.py @@ -46,6 +46,14 @@ def repeat(self, condition: str, repeat: str) -> str: """Return the replicate ID of a biological-repeat folder, or the folder label.""" return self.repeats.get((condition, repeat), repeat) + def order(self, condition: str, repeat: str) -> tuple[str, str, str]: + """Return the sort key of a biological-repeat folder: condition, replicate ID, folder. + + Within a condition the plots list the replicates by the ID they are shown with + (``rep1`` before ``rep2``), not by folder name; the folder breaks a tie. + """ + return (condition, self.repeat(condition, repeat), repeat) + NO_LABELS = RunLabels() """The labels of a run without a description: the table's own names.""" diff --git a/tests/test_embedding_api.py b/tests/test_embedding_api.py index 48ae6a37..7971978e 100644 --- a/tests/test_embedding_api.py +++ b/tests/test_embedding_api.py @@ -73,6 +73,7 @@ def _parameters(function): ], "check_save_step": [("title", POSITIONAL, None)], "save_experiment": [], + "steps": [], "run_step": [], "results_step": [], "check_folder": [], diff --git a/tests/test_notebook_channels.py b/tests/test_notebook_channels.py index a762e0ef..8b6f8063 100644 --- a/tests/test_notebook_channels.py +++ b/tests/test_notebook_channels.py @@ -207,7 +207,7 @@ def test_incomplete_tagging_blocks_completion(plain, out): assert "3 channel(s) still to tag" in grid.status.value assert "1 image type(s) still to choose" in grid.status.value tag_all(nb) - assert grid.complete and "Every channel of every one of the 4 units" in grid.status.value + assert grid.complete and "Every channel of every one of the 4 folders" in grid.status.value def test_the_dropdown_has_no_empty_option_and_an_unused_slot_needs_a_label(plain, out): @@ -266,10 +266,12 @@ def test_a_label_used_in_only_some_units_says_what_will_be_refused(plain, out): grid = nb.channel_grid tag_all(nb, skip=[("drug", "rep2", "single")]) text = grid.usage_html.value - assert "used in 3 of 4 units: an analysis of DNA will be refused for the other units" in text + assert ( + "used in 3 of 4 folders: an analysis of DNA will be refused for the other folders" in text + ) assert "A_SCOPE_INCOMPLETE" in text tag_all(nb) - assert "used in all 4 units" in grid.usage_html.value + assert "used in all 4 folders" in grid.usage_html.value assert "will be refused" not in grid.usage_html.value @@ -299,7 +301,7 @@ def test_a_pre_tag_from_the_header_is_only_a_suggestion_until_confirmed(tmp_path assert not saved(out).units # nothing is the user's answer yet assert not grid.complete assert len(grid.confirm_buttons) == 1 # one confirm for the one layout - assert "4 unit(s)" in grid.confirm_buttons[0].description + assert "4 folder(s)" in grid.confirm_buttons[0].description grid.confirm_buttons[0].click() assert all(u.channels == (DNA, ACTIN, PHASE) for u in saved(out).units.values()) assert "proposed, not confirmed" not in repr(grid.blocks.children) @@ -315,7 +317,7 @@ def test_units_with_different_header_names_are_different_layouts(tmp_path, out): set_labels(nb, ["ACTIN", "DNA", "PHASE"]) grid = nb.channel_grid assert len(grid.confirm_buttons) == 1 # only the layout that equals the labels exactly - assert "2 unit(s)" in grid.confirm_buttons[0].description + assert "2 folder(s)" in grid.confirm_buttons[0].description assert grid.cells[(("drug", "rep1", "single"), 0)].value is None @@ -353,7 +355,7 @@ def test_confirming_the_colour_layout_declares_r_g_b_and_completes_every_unit(tm nb = open_screen(rgb_tree(tmp_path), out) set_labels(nb, ["DNA"]) grid = nb.channel_grid - assert len(grid.confirm_buttons) == 1 and "4 unit(s)" in grid.confirm_buttons[0].description + assert len(grid.confirm_buttons) == 1 and "4 folder(s)" in grid.confirm_buttons[0].description grid.confirm_buttons[0].click() answers = saved(out) assert all( @@ -393,7 +395,7 @@ def test_every_answer_is_saved_and_a_reopened_notebook_shows_it(plain, out): assert ui.is_visible(grid.widget) and grid.complete assert all(d.value == (DNA, ACTIN, PHASE)[k[1]] for k, d in grid.cells.items()) assert all(r.value == "channels" for r in again.channel_types.radios.values()) - assert "Every channel of every one of the 4 units" in grid.status.value + assert "Every channel of every one of the 4 folders" in grid.status.value def test_changing_the_labels_takes_back_the_tags_of_a_label_that_is_gone(plain, out): diff --git a/tests/test_notebook_check_save.py b/tests/test_notebook_check_save.py index 79ed4201..3e1d04e5 100644 --- a/tests/test_notebook_check_save.py +++ b/tests/test_notebook_check_save.py @@ -191,7 +191,9 @@ def test_the_experiment_is_restated_in_words_with_one_question_per_replicate(exp def test_save_says_saved_and_enables_run(exp): nb = saved_nb(exp) - assert "Saved " in nb.save_status.value and "Step 3 can run" in nb.save_status.value + assert ( + "Saved " in nb.save_status.value and "The run (step 12) can start" in nb.save_status.value + ) assert not nb.run_button.disabled assert nb.confirmed_image_type == "fluorescence" _, _, out = exp diff --git a/tests/test_notebook_file.py b/tests/test_notebook_file.py index d21ad8c8..48aca5cd 100644 --- a/tests/test_notebook_file.py +++ b/tests/test_notebook_file.py @@ -53,14 +53,12 @@ def test_notebook_is_one_readable_markdown_cell_and_one_runnable_cell(): def test_notebook_uses_stable_page_api_not_a_new_app_method(): source = _source() assert "notebook.app(" not in source - for call in ( - "notebook.welcome()", - "notebook.folder_step()", - "notebook.axis_step()", - "notebook.run_step()", - "notebook.results_step()", - ): + # One stepper holds steps 1-13 (owner, 2026-11-09); the statistics stay a page of their own. + for call in ("notebook.welcome()", "notebook.steps()", "notebook.results_step()"): assert call in source + for page in ("folder_step(", "axis_step(", "channels_step(", "check_save_step(", "run_step("): + assert f"notebook.{page}" not in source # never shown twice beside the stepper + assert "PreprocessingNotebook(describe=True)" in source assert "display(app)" in source diff --git a/tests/test_run_page_labels.py b/tests/test_run_page_labels.py index 81406675..335a78d5 100644 --- a/tests/test_run_page_labels.py +++ b/tests/test_run_page_labels.py @@ -132,6 +132,16 @@ def test_a_described_run_uses_the_replicate_id_not_the_folder_name(tmp_path, fig assert not any("day2" in t for t in boxes + curves) +def test_replicates_within_a_condition_are_ordered_by_replicate_id(tmp_path, figures): + # drug/day2 is replicate rep2: by folder name it came before drug/rep1 (PR #241 kept that). + nb, _ = described(tmp_path) + assert nb.run().written + ticks = [t.get_text() for t in figures[-2].axes[1].get_xticklabels()] + assert ticks == ["ctrl / rep1", "ctrl / rep2", "drug / rep1", "drug / rep2"] + (drug,) = [axes for axes in figures[-1].axes if axes.get_title() == "drug"] + assert [t.get_text() for t in drug.get_legend().get_texts()] == ["rep1", "rep2"] + + def test_an_undescribed_run_keeps_the_folder_names(tmp_path, figures): nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out")) nb.run() From 1830efc9204c143bb7777680aefc9a136a30a6a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 07:35:27 +0000 Subject: [PATCH 2/2] Stepper tests, summaries and text follow-ups from the screenshots Widget tests for the stepper (Next, Back, strip state, a chip click, the overview, reopen landing, a changed reopen in orange, the state round trip, the image-type page, consistency only when it warns, the axes page text). Step 1 says where the consistency warnings went; the replicates line leaves out "single"; the pairing help and Describe the masks name the new steps. AGENTS.md map and CHANGELOG. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- AGENTS.md | 4 + CHANGELOG.md | 15 + .../notebook_design.py | 2 +- .../notebook_mask_step.py | 2 +- .../notebook_step_states.py | 11 +- .../notebook_steps.py | 11 + tests/test_notebook_mask_step.py | 2 +- tests/test_notebook_stepper.py | 269 ++++++++++++++++++ 8 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 tests/test_notebook_stepper.py diff --git a/AGENTS.md b/AGENTS.md index 2cfd0a11..6c9980c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -298,6 +298,10 @@ src/morphobricks_preprocessing/ (plan §12 Q26, Q31). Since PP-9 it holds the page methods, __init__ and the shared helpers; the step methods live on mixin classes in the modules below, and it re-exports every public name it had (`__all__`) + notebook_steps.py steps(): the shipped notebook's 13 steps, one at a time (PR 17a; pure + composition of the cards below); notebook_stepper.py the strip, Back/Next + and the overview; notebook_step_states.py each step's done/one-line state + notebook_names.py the names card's rows (conditions on step 4, replicates on step 5) notebook_folder.py step 1: folder, check, issues, labels (_FolderStep) notebook_design.py step 1: the experiment design card (_DesignCard) notebook_axis_step.py step 2: role dropdowns, axis check, missing-axes card (_AxisStep) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30da8ac4..c0bf59e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the ## Unreleased +- **Notebook: one step at a time (UX proposal 2026-11-09, PR 17a).** `PreprocessingNotebook.steps()` + shows the shipped notebook's 13 steps one at a time, with a strip of chips (done green with a tick, + current blue, to do grey, changed by a reopen orange; a chip opens its step) and Back/Next (Next is + never blocked: the save and the run still refuse what is missing). The order is the owner's: images + found, axes, pixel sizes, conditions, replicates (pairing with the replicate and technical-repeat + names, then the consistency card only when it warns), compared against, image type (its own page; + the tag grid has no type column there), stains, tag channels, masks, check and save (starting with a + one-line overview of steps 1-10, each with *change*), run, describe the masks. Pure composition: the + cards, answers, draft, save and run are unchanged; a reopened experiment opens on its first step + still to do (the axes, confirmed once per session). The notebook file shows `notebook.steps()` and + `results_step()`; `folder_step()` and `axis_step()` render as before for embedders. Text: no RGB + sentence on a described run's axes page, no broken picture before the preview, "same way?" *above*, + the reader and CSV lines of *Check and save* behind *Details*, "folder" instead of "unit", and the + steps named by their new numbers. The run page's box plots and curves order the replicates of a + condition by replicate ID (`rep1` before `rep2`), not by folder name. - **Notebook: Step 3 run-page labels (UX proposal 2026-11-09, Q4).** In a described run the intensity plots name the channel tabs, titles and tab findings by channel ID (`DNA`, `ACTIN`, `PHASE`) instead of `C0`/`C1`/`C2`, and the box-plot axis and curve legends use the replicate ID (`rep2`) instead of diff --git a/src/morphobricks_preprocessing/notebook_design.py b/src/morphobricks_preprocessing/notebook_design.py index 543779d0..10ae7e9d 100644 --- a/src/morphobricks_preprocessing/notebook_design.py +++ b/src/morphobricks_preprocessing/notebook_design.py @@ -55,7 +55,7 @@ def _build_design_card(self) -> None: "condition (acquired a day later, for example), choose its group in " "the dropdown. Each group may hold one folder per condition. The dropdown " "offers " - "folder names only; the repeat's own ID is set in the Names card." + "folder names only; the repeat's own ID is set with the names below." ), self.pairing_grid, ] diff --git a/src/morphobricks_preprocessing/notebook_mask_step.py b/src/morphobricks_preprocessing/notebook_mask_step.py index 7f3df601..8b0032a9 100644 --- a/src/morphobricks_preprocessing/notebook_mask_step.py +++ b/src/morphobricks_preprocessing/notebook_mask_step.py @@ -47,7 +47,7 @@ _ICON = "sitemap" _SUGGESTIONS = ( "These are suggestions from the masks' pixels: check the relationships before using them. " - "Each mask keeps the ID it was given on Your masks." + "Each mask keeps the ID it was given on Masks (step 10)." ) diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py index 15406b5e..80db79fe 100644 --- a/src/morphobricks_preprocessing/notebook_step_states.py +++ b/src/morphobricks_preprocessing/notebook_step_states.py @@ -49,6 +49,7 @@ def _reopen_changed(self) -> bool: return state is not None and state.changed def _images_state(self) -> StepState: + """Return step 1: images and folders found, the masks, and changes since the save.""" pre = self.preflight if pre is None: return StepState(False, "not checked yet") @@ -74,6 +75,7 @@ def _axes_state(self) -> StepState: return StepState(True, ", ".join(role for role in mapping.roles if role) + " confirmed") def _sizes_state(self) -> StepState: + """Return step 3: the pixel sizes as the calibration card shows them.""" if self.preflight is None or not self.preflight.metadata.files: return StepState(False, "after the folder check") key = (id(self.preflight), self.confirmed_mapping, self.missing_as_singleton.value) @@ -90,6 +92,7 @@ def _changed(self, index: int, done: bool) -> bool: return not done and index in ANSWER_STEPS and self._reopen_changed() def _conditions_state(self) -> StepState: + """Return step 4: the condition IDs as shown, with their shown-as names.""" if self.draft is None: return StepState(False, "after the folder check") wanted = self._wanted_ids() @@ -104,6 +107,7 @@ def _conditions_state(self) -> StepState: return StepState(done, text or "none", self._changed(3, done)) def _replicates_state(self) -> StepState: + """Return step 5: the replicate IDs, the pairing and the technical-repeat IDs.""" if self.draft is None: return StepState(False, "after the folder check") wanted = self._wanted_ids() @@ -115,8 +119,9 @@ def _replicates_state(self) -> StepState: ] if paired: text += f" ({'; '.join(paired)})" - if [i for i in technical if i != "single"]: - text += " · technical: " + ", ".join(i or "?" for i in technical) + named = [i or "?" for i in technical if i != "single"] + if named: + text += " · technical: " + ", ".join(named) done = all(repeats) and all(technical) return StepState(done, text or "none", self._changed(4, done)) @@ -132,6 +137,7 @@ def _references_state(self) -> StepState: return StepState(done, text, self._changed(5, done)) def _image_type_state(self) -> StepState: + """Return step 7: the image types answered, by kind, and how many are left.""" if self._channels_key is None: return StepState(False, "after the axes") types = self.channel_types @@ -155,6 +161,7 @@ def _stains_state(self) -> StepState: return StepState(done, text, self._changed(7, done)) def _tags_state(self) -> StepState: + """Return step 9: whether every channel of every folder is tagged.""" if self._channels_key is None or not self.channel_labels.confirmed: return StepState(False, "after the stains") layouts = self._channel_layouts diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index 13b49480..282bed24 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -170,6 +170,7 @@ def _step_waits(self) -> dict: """Return, by step (0-based), why it cannot be answered yet (HTML, empty when it can).""" after_check = self._wait_for_check return { + 0: self._consistency_note, 2: lambda: "" if self.preflight is not None else after_check(), 3: after_check, 4: after_check, @@ -180,6 +181,16 @@ def _step_waits(self) -> dict: 12: self._wait_for_mask_step, } + def _consistency_note(self) -> str: + """Say where the consistency warnings are, since step 1 counts them but shows them later.""" + if not ui.is_visible(self.consistency_slot): + return "" + return ui.status( + "info", + "The consistency warnings are on Replicates (step 5).", + ["They depend on which folders are one replicate: pair the folders there first."], + ) + def _wait_for_check(self) -> str: if self.draft is not None: return "" diff --git a/tests/test_notebook_mask_step.py b/tests/test_notebook_mask_step.py index 93d6352e..df4a1a2b 100644 --- a/tests/test_notebook_mask_step.py +++ b/tests/test_notebook_mask_step.py @@ -143,7 +143,7 @@ def test_describe_the_masks_reuses_the_ids_of_your_masks_and_asks_no_name(tmp_pa nb.mask_step_button.click() status, shown = nb.mask_step_status.value, nb.mask_step_result.value assert "name each mask" not in status - assert "keeps the ID it was given on Your masks" in status + assert "keeps the ID it was given on Masks (step 10)" in status assert "MaskDeclared kindKind (suggested)" in shown assert "Nuclei_seg (nuclei)objectsone object per label" in shown assert "cellsnot described yet" in shown diff --git a/tests/test_notebook_stepper.py b/tests/test_notebook_stepper.py new file mode 100644 index 00000000..153240e7 --- /dev/null +++ b/tests/test_notebook_stepper.py @@ -0,0 +1,269 @@ +"""The shipped notebook's stepper: 13 steps, one on screen at a time (PR 17a, 2026-11-09). + +Widgets are driven headless, as the user would: the strip's chips, Back and Next, and the +existing cards on each page. The stepper is pure composition, so the answers are those of the +cards; these tests pin the layout (order, one page shown, chip states, reopen landing) and that +moving between pages never changes or resets an answer. +""" + +import pytest + +from test_notebook_check_save import TAGS, add_folder, answer_all, exp # noqa: F401 + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.notebook_stepper import ( # noqa: E402 + Step, + Stepper, + StepState, +) +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 + +ORDER = [ + "Images found", + "Axes", + "Pixel sizes", + "Conditions", + "Replicates", + "Compared against", + "Image type", + "Stains", + "Tag channels", + "Masks", + "Check and save", + "Run", + "Describe the masks", +] + + +def stepped(root, masks, out, *, axes=True): + """Return a described notebook shown as the stepper, checked (and its axes confirmed).""" + nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True) + nb.welcome() + nb.steps() + nb.results_step() + nb.masks_folder.value = str(masks) + nb.check_button.click() + if axes: + nb.preview_checked.set(True) + nb.show_preview() + nb.confirm_button.click() + return nb + + +def kinds(nb): + """Return each chip's colour class.""" + return [ + next(c.removeprefix("mb-chip-") for c in chip._dom_classes if c.startswith("mb-chip-")) + for chip in nb._stepper.chips + ] + + +def shown(nb): + """Return the index of the one page on screen (and check there is exactly one).""" + visible = [i for i, page in enumerate(nb._stepper.pages) if ui.is_visible(page)] + assert len(visible) == 1 + return visible[0] + + +def snapshot(nb): + """Return every answer and the value of every answering widget.""" + values = { + "ids": {key: box.value for key, box in nb.id_inputs.items()}, + "names": {key: box.value for key, box in nb.name_inputs.items()}, + "references": {key: box.value for key, box in nb.reference_boxes.items()}, + "types": {key: radio.value for key, radio in nb.channel_types.radios.items()}, + "tags": {key: drop.value for key, drop in nb.channel_grid.cells.items()}, + "same_way": {key: toggle.value for key, toggle in nb.same_way.items()}, + "mask": nb.mask_card.questions["Nuclei"].choice.value, + "pairing": [row.children[2].value for row in nb.pairing_grid.children[1:]], + } + return nb.draft_answers, nb.confirmed_mapping, values + + +# --- the generic stepper ------------------------------------------------------------------------ + + +def _steps(done): + return [ + Step(f"S{i}", "purpose", [], lambda i=i: StepState(i in done, f"line {i}")) + for i in range(4) + ] + + +def test_next_and_back_move_one_page_and_never_block(): + stepper = Stepper(_steps(done=set())) + pages = lambda: [ui.is_visible(page) for page in stepper.pages] # noqa: E731 + assert stepper.current == 0 and pages() == [True, False, False, False] + assert not ui.is_visible(stepper.back) and stepper.next.description == " Next: S1" + stepper.next.click() # nothing is done: Next still moves on + stepper.next.click() + assert stepper.current == 2 and pages() == [False, False, True, False] + assert stepper.back.description == " Back: S1" + stepper.back.click() + assert stepper.current == 1 + stepper.go(3) + assert not ui.is_visible(stepper.next) and ui.is_visible(stepper.back) + + +def test_the_strip_marks_done_current_and_to_do_and_a_chip_opens_its_step(): + stepper = Stepper(_steps(done={0, 2})) + stepper.chips[2].click() + assert stepper.current == 2 + marks = [[c for c in chip._dom_classes if c.startswith("mb-chip-")] for chip in stepper.chips] + assert marks == [["mb-chip-done"], ["mb-chip-todo"], ["mb-chip-current"], ["mb-chip-todo"]] + assert [chip.description for chip in stepper.chips] == ["✓ 1 S0", "2 S1", "✓ 3 S2", "4 S3"] + assert stepper.chips[1].tooltip == "line 1" + + +def test_the_overview_restates_each_step_with_a_change_button(): + stepper = Stepper(_steps(done={1})) + card = stepper.overview(3) + rows = card.children[1:] + assert len(rows) == 3 + assert "S1 · line 1" in rows[1].children[0].value and "✓" in rows[1].children[0].value + rows[2].children[1].click() + assert stepper.current == 2 + + +# --- the notebook's 13 steps -------------------------------------------------------------------- + + +def test_the_steps_are_in_the_decided_order_with_one_page_on_screen(exp): # noqa: F811 + nb = stepped(*exp, axes=False) + assert [step.title for step in nb._stepper.steps] == ORDER + assert nb.steps() is nb._stepper.widget # built once + assert shown(nb) == 0 and nb._stepper.current == 0 # a first check stays on its results + assert kinds(nb)[:3] == ["current", "todo", "done"] # axes to confirm; sizes are read + nb._stepper.chips[1].click() + assert shown(nb) == 1 and kinds(nb)[0] == "done" + page = nb._stepper.pages[1] + assert "Step 2 of 13 · Axes" in page.children[1].children[0].value + + +def test_the_pages_hold_the_existing_cards_in_the_new_places(exp): # noqa: F811 + nb = stepped(*exp) + contains = lambda i, w: _holds(nb._stepper.pages[i], w) # noqa: E731 + assert contains(0, nb.check_button) and contains(0, nb.masks_card) + assert contains(1, nb.axis_box) and contains(2, nb.calibration_card) + assert contains(3, nb.condition_rows) and not contains(3, nb.replicate_rows) + assert contains(4, nb.design_card) and contains(4, nb.replicate_rows) + assert contains(5, nb.references_card) and contains(6, nb.type_rows.widget) + assert contains(7, nb.channel_labels.widget) and contains(8, nb.channel_grid.widget) + assert contains(9, nb.masks_box) and contains(10, nb.check_card) + assert contains(10, nb.overview_box) and contains(11, nb.run_button) + assert contains(12, nb.mask_step_card) and not contains(11, nb.mask_step_card) + # The names rows are split between steps 4 and 5, and still the names card's grid. + (conditions,) = nb.condition_rows.children + assert "Condition" in conditions.children[0].children[0].value + assert list(nb.names_grid.children) == [conditions, *nb.replicate_rows.children] + + +def _holds(widget, target): + if widget is target: + return True + return any(_holds(child, target) for child in getattr(widget, "children", ())) + + +def test_the_image_type_has_its_own_step_and_the_grid_no_type_column(exp): # noqa: F811 + nb = stepped(*exp) + for row, text in zip(nb.channel_labels.rows, TAGS, strict=True): + row.id_box.value = text + nb.channel_labels.confirm.button.click() + assert len(nb.channel_types.radios) == 4 # one per folder, on the image-type step + assert not any(_holds(nb.channel_grid.widget, r) for r in nb.channel_types.radios.values()) + assert "Not finished: 4 image type(s)" in nb.type_rows.status.value + key = next(iter(nb.channel_types.radios)) + nb.channel_types.radios[key].value = "channels" + assert nb.draft_answers.units[key].image_type == "channels" + assert "Not finished: 3 image type(s)" in nb.type_rows.status.value + assert nb.channel_types.radios[key].value == "channels" # rebuilt from the answer + + +def test_moving_through_every_page_keeps_every_answer(exp): # noqa: F811 + nb = stepped(*exp) + answer_all(nb) + nb.name_inputs[("conditions", "drug")].value = "Drug A" + before = snapshot(nb) + stepper = nb._stepper + for _ in ORDER: + stepper.next.click() + for _ in ORDER: + stepper.back.click() + for chip in reversed(stepper.chips): + chip.click() + assert snapshot(nb) == before + assert nb.draft_answers.conditions["drug"].display_name == "Drug A" + assert all(k == "done" for k in kinds(nb)[1:10]) and kinds(nb)[0] == "current" + assert kinds(nb)[10] == "todo" # not saved yet + assert nb.save_experiment() + assert kinds(nb)[10] == "done" + + +def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noqa: F811 + first = stepped(*exp) + answer_all(first) + assert first.save_experiment() + nb = stepped(*exp, axes=False) + assert nb._stepper.current == 1 and shown(nb) == 1 + nb.preview_checked.set(True) + nb.show_preview() + nb.confirm_button.click() + assert all(k == "done" for k in kinds(nb)[:11] if k != "current") + assert "saved " in nb._stepper.chips[10].tooltip + # The saved reopening folds the cards; a page that shows one unfolds it. + assert nb._folded and not ui.is_visible(nb.design_card) + nb._stepper.chips[4].click() + assert not nb._folded and ui.is_visible(nb.design_card) + + +def test_a_changed_reopen_turns_the_affected_steps_orange(tmp_path): + root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out" + for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1"]): + add_folder(root, masks, folder, i + 1) + first = stepped(root, masks, out) + answer_all(first) + assert first.save_experiment() + add_folder(root, masks, "drug/rep2", 9) + nb = stepped(root, masks, out) + states = nb._stepper.states() + assert "since saved: 1 new, 0 gone" in states[0].text + assert states[8].changed and states[10].changed # the new folder's tags; save again + assert not states[3].changed and states[3].done # the conditions did not change + assert kinds(nb)[8] == "changed" and kinds(nb)[10] == "changed" + assert nb._stepper.current == 1 # landed on the axes: each session confirms them again + + +def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path): + root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out" + for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"]): + add_folder(root, masks, folder, i + 1) + nb = stepped(root, masks, out) + assert _holds(nb._stepper.pages[4], nb.consistency_card) + assert not _holds(nb._stepper.pages[0], nb.consistency_card) + assert ui.is_visible(nb.consistency_slot) # rep2 and day2 are each in one condition only + assert "on Replicates (step 5)" in nb._stepper.waits[0].value # step 1 says where + (dropdown,) = [ + row.children[2] + for row in nb.pairing_grid.children[1:] + if "day2" in row.children[1].children[0].value + ] + dropdown.value = "rep2" + assert not ui.is_visible(nb.consistency_slot) + assert nb._stepper.waits[0].value == "" + assert "drug/day2 = rep2" in nb._stepper.states()[4].text + + +def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(exp): # noqa: F811 + nb = stepped(*exp, axes=False) + text = "".join(getattr(w, "value", "") for w in nb._stepper.pages[1].children[1].children) + assert "fluorescence or RGB" not in text + assert not ui.is_visible(nb.preview_image) # nothing to show before Show preview + nb.show_preview() + assert ui.is_visible(nb.preview_image) and nb.preview_image.value + described = nb.axis_step() + assert "fluorescence or RGB" not in described.children[1].children[2].value + plain = PreprocessingNotebook().axis_step() # embedders keep their words + assert "fluorescence or RGB" in plain.children[1].children[2].value