From 30d06cf1b667f7542b5315514e32db7ea51eec16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 06:40:26 +0000 Subject: [PATCH 1/2] Run page: channel and replicate IDs, no-problems icon, plan card, mask kinds Step 3 of the notebook, for a described run: plot tabs, titles and findings use channel IDs, plots and legends use replicate IDs (only from the published description the manifest recorded), "no problems found" gets a tick, "What will happen" is hidden after a run until a setting changes, and Describe the masks shows the declared kind beside the suggestion in one Mask column. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- AGENTS.md | 2 + CHANGELOG.md | 10 + pyproject.toml | 1 + .../mask_answers.py | 11 + src/morphobricks_preprocessing/mask_text.py | 37 +-- .../notebook_mask_step.py | 6 +- .../notebook_masks_screen.py | 6 + .../notebook_plots.py | 67 ++++-- .../notebook_run.py | 20 +- src/morphobricks_preprocessing/run_labels.py | 93 ++++++++ tests/test_notebook_mask_step.py | 6 +- tests/test_run_page_labels.py | 218 ++++++++++++++++++ 12 files changed, 438 insertions(+), 39 deletions(-) create mode 100644 src/morphobricks_preprocessing/run_labels.py create mode 100644 tests/test_run_page_labels.py diff --git a/AGENTS.md b/AGENTS.md index fe7ff177..2cfd0a11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,6 +305,8 @@ src/morphobricks_preprocessing/ notebook_run.py step 3: settings, what will happen, the run (_RunStep) notebook_results.py step 4: pooled statistics per group and channel (_ResultsStep) notebook_plots.py the PNG plots and IntensityPanel (the intensity checks card) + run_labels.py RunLabels: channel and replicate IDs of a described run's plots, from the + published description when it is the file the manifest recorded (else C, folder labels) notebook_render.py HTML helpers: issues, balance, labels, role effect, captions, tiles notebook_options.py the steps' choices and fixed settings (ROLE_OPTIONS, ...) notebook_values.py display_range (pure: no widgets, no files) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c87940..30da8ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ are in [docs/plan.md](docs/plan.md) ยง12. Upgrading from v0.3: see the ## Unreleased +- **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 + the folder name (`day2`). The IDs come from the published `morphobricks.toml` only when its SHA-256 + is the one the run's manifest recorded (`run_labels.recorded_labels`); an undescribed run, an edited + file, or a channel position whose units do not all declare one ID keeps `C` and the folder label. + The run tile says "no problems found" with a tick, not the warning sign. "What will happen" is hidden + after a successful run (it planned that run) and returns when a setting changes. *Describe the masks* + shows the declared kind (objects or regions) beside the suggested kind and has one *Mask* column + instead of *Mask type* and *Mask ID* holding the same text. - **Notebook: check and save, reopen, described runs (Stage 1 PR 16b).** `PreprocessingNotebook(describe=True)` (the shipped notebook) saves the experiment as `morphobricks.toml` before the run. *Check and save* restates it in sentences (what each condition is compared against, "compared against nothing" in diff --git a/pyproject.toml b/pyproject.toml index 98d50ebc..e7d2c7dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,7 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.mask_review" = "pure" "morphobricks_preprocessing.mask_binding" = "io" "morphobricks_preprocessing.mask_record" = "io" +"morphobricks_preprocessing.run_labels" = "io" "morphobricks_preprocessing.notebook_axes" = "ui" "morphobricks_preprocessing.notebook_channel_thumbs" = "ui" "morphobricks_preprocessing.notebook_channels" = "ui" diff --git a/src/morphobricks_preprocessing/mask_answers.py b/src/morphobricks_preprocessing/mask_answers.py index 8e53ec5a..a75f5ae0 100644 --- a/src/morphobricks_preprocessing/mask_answers.py +++ b/src/morphobricks_preprocessing/mask_answers.py @@ -51,6 +51,15 @@ def mask_of_type(answers: DraftAnswers, source_type: str) -> tuple[str, MaskAnsw return None +KIND_WORDS = {INSTANCES: "objects", BINARY: "regions"} +"""The words the notebook uses for the two answers: separate objects, or regions.""" + + +def mask_kinds(answers: DraftAnswers) -> dict[str, str]: + """Return what the user declared each answered mask type to be: objects or regions.""" + return {mask.source_type: KIND_WORDS[mask.representation] for mask in answers.masks.values()} + + def mask_ids(answers: DraftAnswers) -> dict[str, str]: """Return the mask ID of every answered mask type, by mask type.""" return {mask.source_type: key for key, mask in answers.masks.items()} @@ -186,10 +195,12 @@ def drawn_on(answers: DraftAnswers, source_type: str, unit: UnitKey | None) -> i __all__ = [ "BINARY", "INSTANCES", + "KIND_WORDS", "MaskAnswerRefused", "drawn_on", "dropped_channels", "mask_ids", + "mask_kinds", "mask_of_type", "masks_unanswered", "masks_without_files", diff --git a/src/morphobricks_preprocessing/mask_text.py b/src/morphobricks_preprocessing/mask_text.py index f2d04a6a..9c9b3ab9 100644 --- a/src/morphobricks_preprocessing/mask_text.py +++ b/src/morphobricks_preprocessing/mask_text.py @@ -91,26 +91,37 @@ def mask_thresholds_used(report: MaskReport) -> str: NOT_DESCRIBED = "not described yet" -"""What the mask ID column says for a mask type the experiment description does not declare.""" +"""What the declared-kind column says for a mask type the description does not declare.""" -def mask_type_rows(report: MaskReport, ids: Mapping[str, str] | None = None) -> list[list[str]]: +def _mask_cell(name: str, ids: Mapping[str, str] | None) -> str: + """Return the mask's name: its ID, with the folder name only when that differs.""" + mask_id = None if ids is None else ids.get(name) + return name if mask_id is None else mask_id if mask_id == name else f"{mask_id} ({name})" + + +def mask_type_rows( + report: MaskReport, + ids: Mapping[str, str] | None = None, + declared: Mapping[str, str] | None = None, +) -> list[list[str]]: """Return the mask types as table rows, header first: kind, images, objects, pieces. - With ``ids`` (mask type -> mask ID, from *Your masks* or the published description) a - *Mask ID* column follows the type: the masks are named there, never here. + The first column, *Mask*, is the mask's ID when ``ids`` (mask type -> mask ID, from + *Your masks* or the published description) gives one, followed by the folder name in + brackets only when the two differ; it is one column, not a type and an ID that repeat + each other. With ``declared`` (mask type -> ``objects`` or ``regions``, what the user + answered) a *Declared kind* column states that answer beside the suggested kind, which + stays a suggestion; a type the user has not answered says :data:`NOT_DESCRIBED`. """ - named = ids is not None - rows = [ - ["Mask type", *(["Mask ID"] if named else []), "Kind (suggested)", "Images", "Objects"] - + ["Median pieces per label"] - ] + shown = declared is not None + header = ["Mask", *(["Declared kind"] if shown else []), "Kind (suggested)"] + rows = [[*header, "Images", "Objects", "Median pieces per label"]] for name, summary in sorted(report.types.items()): - kind = _MASK_KINDS[summary.kind] - numbers = (summary.images, summary.objects) - mask_id = [ids.get(name, NOT_DESCRIBED)] if named else [] + answer = [declared.get(name, NOT_DESCRIBED)] if declared is not None else [] rows.append( - [name, *mask_id, kind, *(f"{n:,}" for n in numbers)] + [_mask_cell(name, ids), *answer, _MASK_KINDS[summary.kind]] + + [f"{summary.images:,}", f"{summary.objects:,}"] + [f"{summary.median_pieces_per_label:g}"] ) return rows diff --git a/src/morphobricks_preprocessing/notebook_mask_step.py b/src/morphobricks_preprocessing/notebook_mask_step.py index b4743b87..7f3df601 100644 --- a/src/morphobricks_preprocessing/notebook_mask_step.py +++ b/src/morphobricks_preprocessing/notebook_mask_step.py @@ -242,6 +242,7 @@ def _show_mask_description(self, step: MaskStep, result: MaskStepResult) -> None ], raw=True, ) + kinds = self._mask_kinds() relationships = mask_relationship_rows(report) same = "".join( f"
  • {html.escape(first)} and {html.escape(second)}
  • " @@ -250,7 +251,10 @@ def _show_mask_description(self, step: MaskStep, result: MaskStepResult) -> None hierarchy = html.escape("\n".join(mask_hierarchy_lines(report))) parts = [ _heading("Mask types (suggestions to check)"), - ui.table(mask_type_rows(report, self._mask_ids()), numeric_from=3), + ui.table( + mask_type_rows(report, self._mask_ids(), kinds), + numeric_from=2 if kinds is None else 3, # from the Images column + ), _heading("Relationships found (suggestions to check)"), ui.table(relationships, numeric_from=3) if len(relationships) > 1 diff --git a/src/morphobricks_preprocessing/notebook_masks_screen.py b/src/morphobricks_preprocessing/notebook_masks_screen.py index 7cd69e5a..30b8f962 100644 --- a/src/morphobricks_preprocessing/notebook_masks_screen.py +++ b/src/morphobricks_preprocessing/notebook_masks_screen.py @@ -25,6 +25,7 @@ drawn_on, dropped_channels, mask_ids, + mask_kinds, masks_unanswered, masks_without_files, without_mask, @@ -265,6 +266,11 @@ def _mask_ids(self) -> dict[str, str] | None: answers = getattr(self, "draft_answers", None) return None if answers is None else mask_ids(answers) + def _mask_kinds(self) -> dict[str, str] | None: + """Return what the user declared each mask type to be (objects or regions), or None.""" + answers = getattr(self, "draft_answers", None) + return None if answers is None else mask_kinds(answers) + def _masks_answered(self) -> None: """Take *Check and save* into account after a mask answer.""" self._refresh_check_save() diff --git a/src/morphobricks_preprocessing/notebook_plots.py b/src/morphobricks_preprocessing/notebook_plots.py index 152f1a56..e67c91ee 100644 --- a/src/morphobricks_preprocessing/notebook_plots.py +++ b/src/morphobricks_preprocessing/notebook_plots.py @@ -40,6 +40,7 @@ from .notebook_render import _label from .notebook_values import display_range from .planes import Plane +from .run_labels import NO_LABELS, RunLabels from .validation import plural @@ -61,6 +62,7 @@ class IntensityPanel: def __init__(self, title: str, explanation: str): # noqa: ANN204 self.records: list[PlaneRecord] = [] self.report: IntensityReport | None = None + self.labels: RunLabels = NO_LABELS self._used: tuple[tuple, IntensityCheckSettings] | None = None self._build_settings_box() self.status = widgets.HTML() @@ -207,12 +209,17 @@ def restore_defaults(self) -> None: """Set the default limits again.""" self.use(IntensityCheckSettings()) - def show(self, records: Sequence[PlaneRecord]) -> None: - """Check the intensities of ``records`` and show the warnings and plots.""" + def show(self, records: Sequence[PlaneRecord], labels: RunLabels = NO_LABELS) -> None: + """Check the intensities of ``records`` and show the warnings and plots. + + ``labels`` names the channels and biological repeats by the IDs of the run's + description; the default keeps the table's own names (``C0``, the folder labels). + """ self.records = list(records) + self.labels = labels channels = self.records[0].channel_count if self.records else 1 with self.channel.hold_trait_notifications(): - self.channel.options = [(f"C{c}", c) for c in range(channels)] + self.channel.options = [(labels.channel(c), c) for c in range(channels)] self.channel.value = 0 ui.set_visible(self.channel_row, channels > 1) self._check() @@ -220,7 +227,7 @@ def show(self, records: Sequence[PlaneRecord]) -> None: def clear(self) -> None: """Hide the panel and forget the table.""" - self.records, self.report = [], None + self.records, self.report, self.labels = [], None, NO_LABELS self.status.value = self.tiles.value = self.findings.value = "" self.boxes.value = self.curves.value = b"" ui.set_visible(self.card, False) @@ -269,7 +276,7 @@ def _check(self) -> None: ("โ”", f"{counts['channel_without_finite_pixels']:,}", "channels not checked"), ] ) - self.findings.value = intensity_findings_html(report) + self.findings.value = intensity_findings_html(report, self.labels) ui.set_visible(self.plots, bool(report.summaries)) self._draw() @@ -281,21 +288,28 @@ def _draw(self) -> None: return channel = self.channel.value or 0 boxes = repeat_boxes(report, channel) - self.boxes.value = intensity_boxes_png(boxes, report.high_percentile, channel) or b"" - curves = repeat_distributions(self.records, channel) - self.curves.value = ( - repeat_curves_png(curves, report.shifted_repeats(channel), channel) or b"" + labels = self.labels + self.boxes.value = ( + intensity_boxes_png(boxes, report.high_percentile, channel, labels) or b"" ) + curves = repeat_distributions(self.records, channel) + shifted = report.shifted_repeats(channel) + self.curves.value = repeat_curves_png(curves, shifted, channel, labels) or b"" -def intensity_findings_html(report: IntensityReport) -> str: +def intensity_findings_html(report: IntensityReport, labels: RunLabels = NO_LABELS) -> str: """Render the intensity warnings as a table, then what to do about each kind.""" if not report.issues: return "" rows = [["Check", "Channel", "Image or biological repeat", "What was found"]] for issue in report.issues: rows.append( - [_CHECK_LABELS[issue.code], f"C{issue.channel}", ", ".join(issue.items), issue.message] + [ + _CHECK_LABELS[issue.code], + labels.channel(issue.channel), + ", ".join(issue.items), + issue.message, + ] ) actions = [] for code in INTENSITY_CODES: @@ -308,12 +322,16 @@ def intensity_findings_html(report: IntensityReport) -> str: def intensity_boxes_png( - boxes: Sequence[RepeatBox], high_percentile: float, channel: int + boxes: Sequence[RepeatBox], + high_percentile: float, + channel: int, + labels: RunLabels = NO_LABELS, ) -> bytes | None: """Box plots of the image medians and high percentiles per condition and biological repeat. Images with a warning in the channel are red diamonds; a biological repeat - reported as shifted has an amber box. + reported as shifted has an amber box. With ``labels`` the channel is named by its ID and + each biological repeat by its replicate ID (after its condition). """ if not boxes: return None @@ -350,9 +368,13 @@ def intensity_boxes_png( axes.set_ylabel(name, fontsize=9) axes.grid(axis="y", alpha=0.3) pair[1].set_xticks( - list(range(n)), [box.label for box in boxes], rotation=30, ha="right", fontsize=8 + list(range(n)), + [f"{b.condition} / {labels.repeat(b.condition, b.biological_repeat)}" for b in boxes], + rotation=30, + ha="right", + fontsize=8, ) - pair[0].set_title(f"Channel C{channel}: one point per image", fontsize=10) + pair[0].set_title(f"Channel {labels.channel(channel)}: one point per image", fontsize=10) pair[0].legend( handles=[ Line2D([], [], color="#d62728", marker="D", linestyle="", label="image with a warning"), @@ -366,11 +388,17 @@ def intensity_boxes_png( return _png(figure) -def repeat_curves_png(curves: dict, shifted: set[tuple[str, str]], channel: int) -> bytes | None: +def repeat_curves_png( + curves: dict, + shifted: set[tuple[str, str]], + channel: int, + labels: RunLabels = NO_LABELS, +) -> bytes | None: """Overlay the cumulative intensity distribution of each biological repeat, per condition. ``curves`` is :func:`.intensity.repeat_distributions`; shifted repeats are - dashed. At most 12 conditions are drawn. + dashed. At most 12 conditions are drawn. With ``labels`` the channel and each curve are + named by the channel and replicate IDs. """ conditions = list(curves)[:_MAX_PLOTTED_GROUPS] if not conditions: @@ -385,13 +413,14 @@ def repeat_curves_png(curves: dict, shifted: set[tuple[str, str]], channel: int) axes = figure.add_subplot(rows, columns, index + 1) for repeat, histogram in curves[condition].items(): dashed = (condition, repeat) in shifted + name = labels.repeat(condition, repeat) axes.step( histogram.values, cumulative_share(histogram), where="post", linewidth=1.4, linestyle="--" if dashed else "-", - label=f"{repeat} (shifted)" if dashed else repeat, + label=f"{name} (shifted)" if dashed else name, ) if high > low: axes.set_xlim(low, high) @@ -403,7 +432,7 @@ def repeat_curves_png(curves: dict, shifted: set[tuple[str, str]], channel: int) axes.tick_params(labelsize=7) axes.grid(alpha=0.3) axes.legend(fontsize=7, frameon=False, loc="lower right") - title = f"Channel C{channel}: one curve per biological repeat" + title = f"Channel {labels.channel(channel)}: one curve per biological repeat" if len(curves) > _MAX_PLOTTED_GROUPS: title += f" (first {_MAX_PLOTTED_GROUPS} of {len(curves)} conditions)" figure.suptitle(title, fontsize=10) diff --git a/src/morphobricks_preprocessing/notebook_run.py b/src/morphobricks_preprocessing/notebook_run.py index 40eb3518..ab2afc21 100644 --- a/src/morphobricks_preprocessing/notebook_run.py +++ b/src/morphobricks_preprocessing/notebook_run.py @@ -31,6 +31,7 @@ from .planes import DEFAULT_MEMORY_LIMIT from .report import issue_counts, rebuild_line from .run import RESTART, Preflight, RunMode, RunResult, run_experiment +from .run_labels import recorded_labels from .validation import has_errors, plural @@ -83,7 +84,7 @@ def _build_run_step(self) -> None: self.mode, self.verify_content, ): - control.observe(lambda _: self._refresh_run_step(), names="value") + control.observe(lambda _: self._settings_edited(), names="value") self.histogram_range.observe( lambda change: ui.set_visible(self.robust_box, change["new"] == "robust"), names="value" ) @@ -212,6 +213,16 @@ def _recommended_changed(self) -> None: ) self._refresh_run_step() + def _settings_edited(self) -> None: + """Show "What will happen" again after a setting changed: it plans the next run.""" + self._plan_for_next_run = True + self._refresh_run_step() + + def _refresh_plan_card(self) -> None: + """Hide "What will happen" after a run until a setting changes: it plans a *next* run.""" + done = self.result is not None and self.result.written + ui.set_visible(self.plan_card, not done or getattr(self, "_plan_for_next_run", False)) + def _refresh_run_step(self) -> None: """Update what is still to do before the run, and what will happen.""" if self.result is None: @@ -260,6 +271,7 @@ def _refresh_run_step(self) -> None: self.readiness.value = title + ui.checklist(items) self.run_button.disabled = bool(missing) self.plan.value = self._plan_html(review if folder_ok else None) + self._refresh_plan_card() self._refresh_mask_step() def _design_items(self) -> list[tuple[bool, str]]: @@ -348,6 +360,8 @@ def _run_failed(self, exc: Exception) -> None: def _finish_run(self, result: RunResult) -> None: """Show what the run did and, when it wrote the table, its intensity checks.""" + self._plan_for_next_run = False + self._refresh_plan_card() self._show_run_result(result) if result.written: self._recorded_after_run() @@ -427,7 +441,7 @@ def _show_run_tiles(self, result: RunResult) -> None: ("โœ๏ธ", f"{len(result.changed):,}", "changed images"), ("๐Ÿ—‘๏ธ", f"{len(result.removed):,}", "images removed"), ("โ›”", f"{len(result.excluded):,}", "excluded images"), - ("โš ๏ธ", issue_counts(result.issues), "checks"), + ("โš ๏ธ" if result.issues else "โœ…", issue_counts(result.issues), "checks"), ] self.run_tiles.value = ui.tiles(tiles) @@ -435,7 +449,7 @@ def _show_run_checks(self, path: Path) -> None: """Show the intensity checks and plots of the table the run wrote.""" try: with self.log.attached(): - self.run_checks.show(read_plane_histograms(path)) + self.run_checks.show(read_plane_histograms(path), recorded_labels(path.parent)) except Exception as exc: # the table is written; only the checks failed self.log.lines.append(traceback.format_exc()) self.run_checks.fail(exc) diff --git a/src/morphobricks_preprocessing/run_labels.py b/src/morphobricks_preprocessing/run_labels.py new file mode 100644 index 00000000..47a41de6 --- /dev/null +++ b/src/morphobricks_preprocessing/run_labels.py @@ -0,0 +1,93 @@ +"""The names a run's plots use: channel and replicate IDs from the published description. + +A histogram table names its channels by position (``C0``, ``C1``, ...) and its images by the +folder labels (condition, biological repeat). When the run was described, the manifest records +the SHA-256 of the ``morphobricks.toml`` it read; the file in the output folder is used here +only when it still has exactly those bytes, so the IDs shown are the ones the run was made +with. Nothing is guessed: a channel position whose units do not all declare the same ID, a +repeat folder the file does not name, a changed or unreadable file, or a run without a +description all keep the table's own names (``C0``, the folder label). +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path + +from .description import Description, DescriptionError, description_from_bytes +from .description_file import DESCRIPTION_FILENAME, read_file_bytes, sha256_hex +from .manifest import MANIFEST_FILENAME, ManifestFormatError, read_manifest + + +@dataclass(frozen=True) +class RunLabels: + """The IDs a described run gives its channels and biological repeats. + + Attributes + ---------- + channels + The channel ID at each table position, or None where the description does not give + one single ID for every unit. + repeats + ``(condition folder, biological-repeat folder)`` -> the replicate ID. + """ + + channels: tuple[str | None, ...] = () + repeats: Mapping[tuple[str, str], str] = field(default_factory=dict) + + def channel(self, index: int) -> str: + """Return the channel's ID, or ``C`` when the run does not give one.""" + known = self.channels[index] if index < len(self.channels) else None + return known or f"C{index}" + + 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) + + +NO_LABELS = RunLabels() +"""The labels of a run without a description: the table's own names.""" + + +def labels_from_description(description: Description) -> RunLabels: + """Return the channel and replicate IDs ``description`` declares.""" + units = [ + unit + for repeat in description.repeats.values() + for member in repeat.members.values() + for unit in member.technical_repeats.values() + ] + width = min((len(unit.channels) for unit in units), default=0) + channels = tuple( + ids.pop() if len(ids := {unit.channels[i] for unit in units}) == 1 else None + for i in range(width) + ) + repeats = { + (description.conditions[condition].source_folder, member.source_repeat): repeat_id + for repeat_id, repeat in description.repeats.items() + for condition, member in repeat.members.items() + } + return RunLabels(channels, repeats) + + +def recorded_labels(output_folder: str | os.PathLike) -> RunLabels: + """Return the IDs of the run recorded in ``output_folder``, or :data:`NO_LABELS`. + + The run's manifest must record a description and the published file must still have the + recorded SHA-256; anything else (no description, a file edited since, an unreadable + manifest or file) gives :data:`NO_LABELS`. + """ + folder = Path(output_folder) + try: + recorded = read_manifest(folder / MANIFEST_FILENAME).experiment_description + data = read_file_bytes(folder / DESCRIPTION_FILENAME) + if recorded is None or data is None or sha256_hex(data) != recorded["sha256"]: + return NO_LABELS + return labels_from_description(description_from_bytes(data)) + except (OSError, ManifestFormatError, DescriptionError): + return NO_LABELS + + +__all__ = ["NO_LABELS", "RunLabels", "labels_from_description", "recorded_labels"] diff --git a/tests/test_notebook_mask_step.py b/tests/test_notebook_mask_step.py index abbd3a5c..93d6352e 100644 --- a/tests/test_notebook_mask_step.py +++ b/tests/test_notebook_mask_step.py @@ -144,9 +144,9 @@ def test_describe_the_masks_reuses_the_ids_of_your_masks_and_asks_no_name(tmp_pa 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 "Mask ID" in shown - assert "nucleiNuclei_segone object per label" in shown - assert "cellsnot described yet" in shown + assert "MaskDeclared kindKind (suggested)" in shown + assert "Nuclei_seg (nuclei)objectsone object per label" in shown + assert "cellsnot described yet" in shown def test_a_child_equally_in_two_parents_is_shown_with_no_parent(tmp_path): diff --git a/tests/test_run_page_labels.py b/tests/test_run_page_labels.py new file mode 100644 index 00000000..81406675 --- /dev/null +++ b/tests/test_run_page_labels.py @@ -0,0 +1,218 @@ +"""Step 3's run page: the labels of a described run (UX proposal 2026-11-09, Q4). + +Channel IDs and replicate IDs come from the published description the run recorded (its +SHA-256 is in the manifest); an undescribed run, or a file edited since, keeps the table's +own names. The widgets are driven headless; plots are read from the figures they draw. +""" + +from dataclasses import replace + +import pytest + +from channel_images import write_masks +from morphobricks_preprocessing.description_file import DESCRIPTION_FILENAME +from morphobricks_preprocessing.run_labels import ( + NO_LABELS, + labels_from_description, + recorded_labels, +) +from morphobricks_preprocessing.validation import warning +from test_notebook_check_save import TAGS, add_folder, answer_all, opened +from test_notebook_ui import bright_tree, confirmed + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_plots # noqa: E402 +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 + +FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"] # day2 is replicate rep2 + + +@pytest.fixture +def figures(monkeypatch): + """Collect every figure the plots draw (instead of its PNG bytes).""" + drawn = [] + monkeypatch.setattr(notebook_plots, "_png", lambda figure: drawn.append(figure) or b"\x89PNG") + return drawn + + +def described(tmp_path, *, tissue=False): + """Return a saved, described notebook (channels DNA/ACTIN/PHASE, day2 paired as rep2).""" + root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out" + for i, folder in enumerate(FOLDERS): + add_folder(root, masks, folder, i + 1) + if tissue: + write_masks(masks, {f"{folder}/a.tif": i + 1}, {"tissue": "region"}) + nb = opened(root, masks, out) + (dropdown,) = [ + row.children[2] + for row in nb.pairing_grid.children[1:] + if "day2" in row.children[1].children[0].value + ] + dropdown.value = "rep2" + answer_all(nb) + if tissue: + nb.mask_card.questions["tissue"].choice.value = "binary" + assert nb.save_experiment() + return nb, out + + +def texts(figure): + """Return the tick labels, titles and legend entries of every axes of ``figure``.""" + found = [figure._suptitle.get_text()] if figure._suptitle else [] + for axes in figure.axes: + found += [t.get_text() for t in axes.get_xticklabels()] + [axes.get_title()] + legend = axes.get_legend() + found += [t.get_text() for t in legend.get_texts()] if legend else [] + return found + + +# --- 1. channel IDs on the plot tabs ---------------------------------------------------------- + + +def test_a_described_run_names_the_plot_tabs_and_titles_by_channel_id(tmp_path, figures): + nb, _ = described(tmp_path) + assert nb.run().written + panel = nb.run_checks + assert [label for label, _ in panel.channel.options] == TAGS + assert [value for _, value in panel.channel.options] == [0, 1, 2] + assert any("Channel DNA: one point per image" in t for t in texts(figures[-2])) + panel.channel.value = 1 + assert any("Channel ACTIN: one curve per biological repeat" in t for t in texts(figures[-1])) + + +def test_an_undescribed_run_keeps_the_c_index(tmp_path, figures): + nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out")) + nb.run() + assert [label for label, _ in nb.run_checks.channel.options] == ["C0", "C1"] + assert any("Channel C0" in t for t in texts(figures[-2])) + + +def test_a_description_edited_after_the_run_is_not_used(tmp_path): + nb, out = described(tmp_path) + assert nb.run().written + assert recorded_labels(out).channel(0) == "DNA" + path = out / DESCRIPTION_FILENAME + path.write_bytes(path.read_bytes() + b"\n# edited\n") # not the bytes the run read + assert recorded_labels(out) is NO_LABELS and NO_LABELS.channel(0) == "C0" + assert recorded_labels(out / "missing") is NO_LABELS + + +def test_a_channel_position_the_units_do_not_agree_on_keeps_its_index(tmp_path): + nb, out = described(tmp_path) + assert nb.run().written + from morphobricks_preprocessing.description_file import read_description + + description = read_description(out / DESCRIPTION_FILENAME) + assert labels_from_description(description).channels == tuple(TAGS) + unit = next( + u + for r in description.repeats.values() + for m in r.members.values() + for u in m.technical_repeats.values() + ) + swapped = type(unit)(unit.source_folder, ("ACTIN", "DNA", "PHASE"), unit.display_name) + member = next(iter(next(iter(description.repeats.values())).members.values())) + changed = {**member.technical_repeats, next(iter(member.technical_repeats)): swapped} + object.__setattr__(member, "technical_repeats", changed) + assert labels_from_description(description).channels == (None, None, "PHASE") + assert labels_from_description(description).channel(0) == "C0" + + +# --- 2. replicate IDs in plots and legends ---------------------------------------------------- + + +def test_a_described_run_uses_the_replicate_id_not_the_folder_name(tmp_path, figures): + nb, _ = described(tmp_path) + assert nb.run().written + boxes, curves = texts(figures[-2]), texts(figures[-1]) + assert "drug / rep2" in boxes and "drug / day2" not in boxes + assert "rep2" in curves and "day2" not in curves # the legend of drug's panel + assert not any("day2" in t for t in boxes + curves) + + +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() + assert "ctrl / rep1" in texts(figures[-2]) and "rep1" in texts(figures[-1]) + + +# --- 3. the no-problems icon ------------------------------------------------------------------- + + +def test_no_problems_found_has_no_warning_icon(tmp_path): + nb, _ = described(tmp_path) + result = nb.run() + assert result.written and not result.issues + assert "no problems found" in nb.run_tiles.value + assert "โš " not in nb.run_tiles.value and "โœ… no problems found" in nb.run_tiles.value + + +def test_problems_keep_the_warning_icon(tmp_path): + nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out")) + result = nb.run() + assert not result.issues and "\u2705 no problems found" in nb.run_tiles.value + nb._show_run_tiles(replace(result, issues=(warning("odd", "Something to read."),))) + assert "\u26a0\ufe0f 1 warning" in nb.run_tiles.value + + +# --- 4. the plan card after a run -------------------------------------------------------------- + + +def test_what_will_happen_is_hidden_after_a_run_until_a_setting_changes(tmp_path): + nb, _ = described(tmp_path) + assert ui.is_visible(nb.plan_card) + assert nb.run().written + assert not ui.is_visible(nb.plan_card) # it described the run that just happened + nb._refresh_run_step() + assert not ui.is_visible(nb.plan_card) + nb.mode.value = "full" # a setting changed: the card plans the next run again + assert ui.is_visible(nb.plan_card) and "Mode full" in nb.plan.value + assert nb.run().written + assert not ui.is_visible(nb.plan_card) + + +def test_the_plan_stays_when_the_run_did_not_write(tmp_path): + nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out")) + nb._call_run = lambda settings: (_ for _ in ()).throw(RuntimeError("boom")) + nb.run() + assert ui.is_visible(nb.plan_card) + + +# --- 5. Describe the masks --------------------------------------------------------------------- + + +def test_describe_the_masks_states_the_declared_kind_beside_the_suggestion(tmp_path): + nb, _ = described(tmp_path, tissue=True) + assert nb.run().written + nb.mask_step_button.click() + shown = nb.mask_step_result.value + assert "MaskDeclared kindKind (suggested)" in shown + assert "Nucleiobjectsone object per label" in shown + assert "tissueregionsone value per image" in shown + + +def test_the_mask_table_has_one_mask_column_not_a_type_and_an_id(tmp_path): + nb, _ = described(tmp_path, tissue=True) + assert nb.run().written + nb.mask_step_button.click() + shown = nb.mask_step_result.value + assert "Mask type" not in shown and "Mask ID" not in shown + assert "NucleiNuclei" not in shown # the same text twice + + +def test_a_mask_without_an_answer_says_so_and_a_different_id_is_kept(tmp_path): + nb, _ = described(tmp_path, tissue=True) + assert nb.run().written + answers = nb.draft_answers + renamed = { + ("Nuclei_seg" if key == "Nuclei" else key): mask + for key, mask in answers.masks.items() + if mask.source_type != "tissue" + } + nb.draft_answers = replace(answers, masks=renamed) + nb.mask_step_button.click() + shown = nb.mask_step_result.value + assert "Nuclei_seg (Nuclei)objects" in shown + assert "tissuenot described yet" in shown From bd4036bfc1dbd32a842e3874be1efe9f422fd6b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 06:49:19 +0000 Subject: [PATCH 2/2] Run page: wider channel tabs so a channel ID is not cut off Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- src/morphobricks_preprocessing/notebook_plots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/morphobricks_preprocessing/notebook_plots.py b/src/morphobricks_preprocessing/notebook_plots.py index e67c91ee..82c994d9 100644 --- a/src/morphobricks_preprocessing/notebook_plots.py +++ b/src/morphobricks_preprocessing/notebook_plots.py @@ -154,7 +154,7 @@ def _build_limits(self) -> None: def _build_plots(self) -> None: """Build the channel choice and the two plots of the chosen channel.""" - self.channel = widgets.ToggleButtons(options=[("C0", 0)], style={"button_width": "64px"}) + self.channel = widgets.ToggleButtons(options=[("C0", 0)], style={"button_width": "96px"}) self.channel_row = widgets.VBox( [ widgets.HTML("Channel"),