From 4975885b8f704addad8291d33ee2e9671897230f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 08:15:28 +0000 Subject: [PATCH] Add make_clean_run(description=...) to build a described run for tests Stage 2 design PR 1. A brick's Stage 2 test needs a run with a published experiment description. make_clean_run takes description (bytes, text or a Description) and builds the declared tree (image_count images per member, RGB colour for rgb units), the declared masks named in mask_types, publishes the bytes, runs preprocessing and the mask step. make_described_run does the same with the mask step optional. Without description, nothing changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm --- CHANGELOG.md | 12 + docs/contract.md | 6 + pyproject.toml | 1 + src/morphobricks_preprocessing/testing.py | 55 ++--- .../testing_described.py | 210 ++++++++++++++++++ tests/description_tree.py | 10 +- tests/test_make_described_run.py | 131 +++++++++++ 7 files changed, 389 insertions(+), 36 deletions(-) create mode 100644 src/morphobricks_preprocessing/testing_described.py create mode 100644 tests/test_make_described_run.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d8fb07..5bca4129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the ## Unreleased +- **`make_clean_run(description=...)` builds a described run for the bricks' Stage 2 tests (Stage 2 + design PR 1).** `description` is a published experiment description (bytes, text or a + `Description`). The helper writes the declared tree (`image_count` images per member, RGB colour + for an `rgb` unit), writes the declared masks named in `mask_types` (now mask IDs in that case), + publishes the bytes as `morphobricks.toml`, runs preprocessing and the mask step, and returns a + `CleanRun` whose new `description` field holds the model (None otherwise). + `testing.make_described_run(folder, description, masks=..., mask_step=...)` is the same with + the mask step optional (masks left `not yet checked`). `axes`, `shape`, `pixel_size`, `design` + and `seed` raise `ValueError` with a description. Tested on the Spec fixtures `v03 v08 v13 v28 + v30 v33 v34`: each builds, opens with `RunInputs`, and `fovs()` gives its condition IDs, repeat + groups, technical repeat IDs, channel IDs and image count. Without `description`, behaviour is + unchanged. - **A draft written under another Spec commit is refused at resume (#223, owner decision of 2026-11-08).** `load_draft` and `publish_draft` raise `DraftFileError` with the new code `spec_commit_mismatch` when the draft's `spec_commit` is not the pinned `SPEC_COMMIT`; diff --git a/docs/contract.md b/docs/contract.md index 82fb18e3..a8f42dbc 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -326,6 +326,12 @@ A `RunInputs` is picklable, so worker processes can use it. `morphobricks_preprocessing.testing.make_clean_run(folder, ...)` writes a tiny experiment of named-axis OME-TIFFs (optionally with masks and a confirmed design) and its clean run, for the bricks' tests. +`make_clean_run(folder, description=)` (also `testing.make_described_run`) +writes instead the tree a published experiment description declares (`image_count` tiny images per +member, `rgb` units as RGB colour, one mask per image for each declared mask ID in `mask_types`), +publishes the bytes as `morphobricks.toml`, runs preprocessing and the mask step, and returns +the same `CleanRun` with `description` set; `axes`, `shape`, `pixel_size`, `design` and `seed` then +raise `ValueError`. Tested for the Spec fixtures `v03 v08 v13 v28 v30 v33 v34`. ### 9.2 Modes, recorded inputs, lock and report (v0.4.0, design v0.4 §1, §4) diff --git a/pyproject.toml b/pyproject.toml index 57779466..1256af9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -237,6 +237,7 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.run_inputs_text" = "pure" "morphobricks_preprocessing.unrecorded" = "io" "morphobricks_preprocessing.testing" = "orchestration" +"morphobricks_preprocessing.testing_described" = "orchestration" "morphobricks_preprocessing.mask_scan" = "io" "morphobricks_preprocessing.mask_pairs" = "pure" "morphobricks_preprocessing.masks" = "io" diff --git a/src/morphobricks_preprocessing/testing.py b/src/morphobricks_preprocessing/testing.py index 975aa964..03cf34b1 100644 --- a/src/morphobricks_preprocessing/testing.py +++ b/src/morphobricks_preprocessing/testing.py @@ -8,7 +8,6 @@ from __future__ import annotations import os -from dataclasses import dataclass from pathlib import Path import numpy as np @@ -16,42 +15,27 @@ from .axes import prefill_mapping from .dataset import biological_repeats +from .description import Description from .design import ExperimentDesign, default_design from .discovery import discover_experiment -from .run import RunResult, run_experiment +from .run import run_experiment +from .testing_described import CleanRun as CleanRun +from .testing_described import make_described_run as make_described_run - -@dataclass(frozen=True) -class CleanRun: - """A clean run made by :func:`make_clean_run`. - - Attributes - ---------- - experiment - The experiment folder. - output - The run's output folder. - masks - The masks folder, or None. - result - What :func:`.run.run_experiment` returned. - """ - - experiment: Path - output: Path - masks: Path | None - result: RunResult +DEFAULTS = {"axes": "CYX", "shape": (2, 16, 20), "pixel_size": 0.5, "design": False} +"""The defaults of the arguments a ``description`` replaces (changing one then raises).""" def make_clean_run( folder: str | os.PathLike, *, - axes: str = "CYX", - shape: tuple[int, ...] = (2, 16, 20), + axes: str = DEFAULTS["axes"], + shape: tuple[int, ...] = DEFAULTS["shape"], mask_types: tuple[str, ...] = (), - pixel_size: float | None = 0.5, - design: bool = False, + pixel_size: float | None = DEFAULTS["pixel_size"], + design: bool = DEFAULTS["design"], seed: int = 0, + description: Description | bytes | str | None = None, ) -> CleanRun: """Write a two-condition experiment of named-axis TIFFs and run preprocessing on it. @@ -74,6 +58,15 @@ def make_clean_run( repeat group), so the run writes ``experiment_design.csv``. seed The random seed of the pixels. + description + A published experiment description (its bytes or text, published unchanged, or a + :class:`.description.Description`): the run is then a described run built by + :func:`.testing_described.make_described_run`, the tree is the one the description + declares (``image_count`` images per member) and ``mask_types`` are IDs of the + description's masks, written and checked by the mask step. ``axes``, ``shape``, + ``pixel_size``, ``design`` and ``seed`` come from the description or are not used, and + changing one raises ``ValueError``. For masks left unchecked, call + :func:`.testing_described.make_described_run` with ``mask_step=False``. Returns ------- @@ -81,9 +74,17 @@ def make_clean_run( Raises ------ + ValueError + If a ``description`` is given with an argument it replaces, or with an unknown mask ID. RuntimeError If the run does not write its outputs (its issues are in the message). """ + if description is not None: + given = {"axes": axes, "shape": shape, "pixel_size": pixel_size, "design": design} + changed = sorted(k for k, v in given.items() if v != DEFAULTS[k]) + (["seed"] * bool(seed)) + if changed: + raise ValueError(f"{changed} cannot be set together with a description") + return make_described_run(folder, description, masks=tuple(mask_types)) root = Path(folder) experiment, masks = root / "experiment", (root / "masks" if mask_types else None) rng = np.random.default_rng(seed) diff --git a/src/morphobricks_preprocessing/testing_described.py b/src/morphobricks_preprocessing/testing_described.py new file mode 100644 index 00000000..5315ae95 --- /dev/null +++ b/src/morphobricks_preprocessing/testing_described.py @@ -0,0 +1,210 @@ +"""A small described preprocessing run for the other bricks' tests (Stage 2 design, PR 1). + +:func:`make_described_run` writes the experiment folder a published experiment description +(``morphobricks.toml``, experiment-v1) declares: one folder per condition, repeat and +technical repeat, ``image_count`` tiny images per member (as many channels as the unit +declares, as RGB colour for an ``rgb`` unit), and, when asked, one mask per image for each +declared mask. It publishes the description beside the output, runs preprocessing on it and, +unless told not to, runs the mask step, so a brick's tests read their inputs through +:class:`.run_inputs.RunInputs` exactly as a user's described run gives them. + +:func:`.testing.make_clean_run` calls it for ``make_clean_run(description=...)``. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import tifffile + +from .axes import AxisMapping, prefill_mapping +from .description import Description, Unit, description_from_bytes +from .description_write import canonical_bytes +from .image_type import RGB +from .run import RunResult, run_experiment +from .run.mask_step import describe_masks, prepare_mask_step + + +@dataclass(frozen=True) +class CleanRun: + """A clean run made by :func:`.testing.make_clean_run` or :func:`make_described_run`. + + Attributes + ---------- + experiment + The experiment folder. + output + The run's output folder. + masks + The masks folder, or None. + result + What :func:`.run.run_experiment` returned. + description + The experiment description of a described run, else None. + """ + + experiment: Path + output: Path + masks: Path | None + result: RunResult + description: Description | None = None + + +PUBLISHED_NAME = "morphobricks.toml" +"""The published description's file name in the output folder.""" +IMAGE_SIZE = 8 +"""The side of every image and mask, in pixels.""" + + +def description_of(description: Description | bytes | str) -> tuple[Description, bytes]: + """Return the description and the exact bytes to publish. + + Bytes and text are published as given (the run records their SHA-256); a + :class:`.description.Description` is written canonically. + + Raises + ------ + morphobricks_preprocessing.description.DescriptionError + If bytes or text are not a valid description. + """ + if isinstance(description, Description): + return description, canonical_bytes(description) + data = description.encode("utf-8") if isinstance(description, str) else bytes(description) + return description_from_bytes(data), data + + +def unit_folder(description: Description, repeat_id: str, condition_id: str, unit: Unit) -> Path: + """Return the unit's folder relative to the experiment root (no level for ``single``).""" + member = description.repeats[repeat_id].members[condition_id] + parts = [description.conditions[condition_id].source_folder, member.source_repeat] + if unit.source_folder != "single": + parts.append(unit.source_folder) + return Path(*parts) + + +def _units(description: Description) -> Iterator[tuple[Path, Unit, int]]: + """Yield ``(folder, unit, image count)`` of every declared unit.""" + for repeat_id, repeat in description.repeats.items(): + for condition_id, member in repeat.members.items(): + for unit in member.technical_repeats.values(): + folder = unit_folder(description, repeat_id, condition_id, unit) + yield folder, unit, member.image_count + + +def _mapping(description: Description) -> AxisMapping: + """Return the axes of the images this description's units are written as.""" + kinds = {unit.image_type == RGB for _, unit, _ in _units(description)} + if len(kinds) != 1: + raise ValueError("a run cannot mix rgb and fluorescence units") + return prefill_mapping("YXS" if kinds == {True} else "CYX") + + +def _write_image(path: Path, unit: Unit, seed: int) -> None: + """Write a tiny image of the unit's channels (RGB colour for an ``rgb`` unit).""" + path.parent.mkdir(parents=True, exist_ok=True) + rng = np.random.default_rng(seed) + if unit.image_type == RGB: + pixels = rng.integers(0, 200, (IMAGE_SIZE, IMAGE_SIZE, 3), dtype=np.uint8) + tifffile.imwrite(path, pixels, photometric="rgb", metadata={"axes": "YXS"}) + return + pixels = rng.integers(0, 200, (len(unit.channels), IMAGE_SIZE, IMAGE_SIZE), dtype=np.uint8) + tifffile.imwrite(path, pixels, photometric="minisblack", metadata={"axes": "CYX"}) + + +def _write_mask(path: Path, representation: str) -> None: + """Write a 2-D mask: two labelled squares (``instances``), else one region of 1.""" + labels = np.zeros((IMAGE_SIZE, IMAGE_SIZE), np.uint8) + labels[1:3, 1:3] = 1 + if representation == "instances": + labels[-3:-1, -3:-1] = 2 + path.parent.mkdir(parents=True, exist_ok=True) + tifffile.imwrite(path, labels, photometric="minisblack", metadata={"axes": "YX"}) + + +def write_described_tree( + root: Path, masks_root: Path | None, description: Description, mask_ids: tuple[str, ...] = () +) -> list[str]: + """Write the images (and the masks of ``mask_ids``) of a description; return image paths. + + Raises + ------ + ValueError + If a mask ID is not declared by the description, or masks are asked without a folder. + """ + unknown = [m for m in mask_ids if m not in description.masks] + if unknown or (mask_ids and masks_root is None): + raise ValueError( + f"mask IDs {unknown} are not declared by the description " + f"(declared: {sorted(description.masks)})" + ) + found: list[str] = [] + for folder, unit, count in _units(description): + for index in range(count): + rel = (folder / f"img{index}.tif").as_posix() + _write_image(root / rel, unit, seed=len(found) + 1) + for mask_id in mask_ids: + mask = description.masks[mask_id] + _write_mask(masks_root / mask.source_type / rel, mask.representation) + found.append(rel) + return sorted(found) + + +def make_described_run( + folder: str | os.PathLike, + description: Description | bytes | str, + *, + masks: tuple[str, ...] = (), + mask_step: bool = True, +) -> CleanRun: + """Write the experiment a description declares, publish it and run preprocessing. + + The folder holds ``experiment`` (the images), ``masks`` (when asked), and ``output`` + (the run, with the published ``morphobricks.toml`` as given). + + Parameters + ---------- + folder + An empty folder. + description + The description: its bytes or text (published unchanged) or a ``Description``. + masks + IDs of the description's masks to write, one file per image and ID (instances: two + labelled squares; binary: one region of 1s). None by default. + mask_step + Run the mask step (:func:`.run.mask_step.describe_masks`) after the run, so the masks + are ``checked``; False leaves them ``not yet checked``. Ignored without ``masks``. + + Returns + ------- + CleanRun + Where everything is, and the run's result. + + Raises + ------ + ValueError + For an unknown mask ID, or a description that mixes rgb and fluorescence units. + RuntimeError + If the run or the mask step does not write its outputs (its issues are in the message). + """ + root = Path(folder) + parsed, data = description_of(description) + experiment, output = root / "experiment", root / "output" + masks_root = root / "masks" if masks else None + mapping = _mapping(parsed) + write_described_tree(experiment, masks_root, parsed, tuple(masks)) + output.mkdir(parents=True, exist_ok=True) + (output / PUBLISHED_NAME).write_bytes(data) + result = run_experiment(experiment, mapping, output_folder=output, masks_folder=masks_root) + if not result.written: + raise RuntimeError( + "The described run was not written: " + "; ".join(map(str, result.issues)) + ) + if masks and mask_step: + step = describe_masks(prepare_mask_step(None, output)) + if step.issues: + raise RuntimeError("The mask step failed: " + "; ".join(map(str, step.issues))) + return CleanRun(experiment, output, masks_root, result, parsed) diff --git a/tests/description_tree.py b/tests/description_tree.py index 49a54490..c355001e 100644 --- a/tests/description_tree.py +++ b/tests/description_tree.py @@ -13,15 +13,7 @@ import tifffile from morphobricks_preprocessing import prefill_mapping - - -def unit_folder(description, repeat_id, condition_id, unit): - """Return the unit's folder relative to the experiment root.""" - member = description.repeats[repeat_id].members[condition_id] - parts = [description.conditions[condition_id].source_folder, member.source_repeat] - if unit.source_folder != "single": - parts.append(unit.source_folder) - return Path(*parts) +from morphobricks_preprocessing.testing_described import unit_folder def write_image(path, channels, seed=1): diff --git a/tests/test_make_described_run.py b/tests/test_make_described_run.py new file mode 100644 index 00000000..9eaf9220 --- /dev/null +++ b/tests/test_make_described_run.py @@ -0,0 +1,131 @@ +"""``make_clean_run(description=...)``: a described run for the bricks' Stage 2 tests. + +Stage 2 design PR 1: each Spec fixture the bricks test against (``v03 v08 v13 v28 v30 v33 +v34``) builds, opens with ``RunInputs``, and ``fovs()`` gives the fixture's canonical IDs +(condition, repeat group, technical repeat ID), channel IDs and image count. The existing +behaviour of ``make_clean_run`` is unchanged (its other tests are in ``test_run_inputs.py``). +""" + +from __future__ import annotations + +from collections import Counter + +import pytest + +from morphobricks_preprocessing.description import description_from_bytes +from morphobricks_preprocessing.description_write import canonical_bytes +from morphobricks_preprocessing.run_inputs import RunInputs +from morphobricks_preprocessing.testing import CleanRun, make_clean_run, make_described_run +from spec_pin import fixtures + +VALID = {item.name.split("_")[0]: item for item in fixtures("valid")} +NAMES = ["v03", "v08", "v13", "v28", "v30", "v33", "v34"] + + +def _expected(description): + """Return ``{(condition ID, repeat group, technical repeat ID, channel IDs): images}``.""" + found: Counter = Counter() + for repeat_id, repeat in description.repeats.items(): + for condition_id, member in repeat.members.items(): + for unit_id, unit in member.technical_repeats.items(): + found[(condition_id, repeat_id, unit_id, unit.channels)] += member.image_count + return found + + +@pytest.mark.parametrize("name", NAMES) +def test_each_fixture_builds_opens_and_gives_its_canonical_ids(tmp_path, name): + description = description_from_bytes(VALID[name].data) + run = make_clean_run(tmp_path / "run", description=VALID[name].data) + assert isinstance(run, CleanRun) and run.result.written + assert run.description == description + assert (run.output / "morphobricks.toml").read_bytes() == VALID[name].data + inputs = RunInputs.open(run.output) + assert inputs.description() == description + fovs = inputs.fovs() + got = Counter( + (f.condition_id, f.repeat_group, f.technical_repeat_id, f.channel_ids) for f in fovs + ) + assert got == _expected(description) + assert len({f.key for f in fovs}) == len(fovs) + + +def test_a_description_object_is_published_canonically(tmp_path): + description = description_from_bytes(VALID["v13"].data) + run = make_clean_run(tmp_path / "run", description=description) + assert (run.output / "morphobricks.toml").read_bytes() == canonical_bytes(description) + assert len(RunInputs.open(run.output).fovs()) == sum(_expected(description).values()) + + +def test_v28_is_written_as_rgb_images(tmp_path): + run = make_clean_run(tmp_path / "run", description=VALID["v28"].data) + fov = RunInputs.open(run.output).fovs()[0] + assert fov.channel_ids == ("R", "G", "B") and fov.axes == "YXS" + + +def test_v08_masks_are_written_and_checked_by_the_mask_step(tmp_path): + run = make_clean_run( + tmp_path / "run", description=VALID["v08"].data, mask_types=("Nuclei", "TumorRegion") + ) + inputs = RunInputs.open(run.output) + fov = inputs.fovs()[0] + assert run.masks is not None and (run.masks / "nuclei").is_dir() + assert inputs.mask_check_status("Nuclei", fov) == "checked" + assert inputs.mask_check_status("TumorRegion", fov) == "checked" + assert inputs.read_mask(fov, "nuclei").max() == 2 + assert set(inputs.read_mask(fov, "tumor_region").ravel().tolist()) == {0, 1} + + +def test_the_mask_step_can_be_left_out(tmp_path): + run = make_described_run( + tmp_path / "run", VALID["v08"].data, masks=("TumorRegion",), mask_step=False + ) + inputs = RunInputs.open(run.output) + assert inputs.mask_check_status("TumorRegion", inputs.fovs()[0]) == "not yet checked" + + +def test_without_masks_there_is_no_masks_folder(tmp_path): + run = make_clean_run(tmp_path / "run", description=VALID["v13"].data) + assert run.masks is None and not (run.experiment.parent / "masks").exists() + + +@pytest.mark.parametrize( + ("change", "named"), + [ + ({"axes": "CZYX"}, "axes"), + ({"shape": (3, 8, 8)}, "shape"), + ({"pixel_size": None}, "pixel_size"), + ({"design": True}, "design"), + ({"seed": 1}, "seed"), + ], +) +def test_an_argument_the_description_replaces_is_refused(tmp_path, change, named): + with pytest.raises(ValueError, match=named): + make_clean_run(tmp_path / "run", description=VALID["v13"].data, **change) + assert not (tmp_path / "run").exists() + + +def test_an_undeclared_mask_is_refused_before_anything_is_written(tmp_path): + with pytest.raises(ValueError, match="'Cells'"): + make_clean_run(tmp_path / "run", description=VALID["v08"].data, mask_types=("Cells",)) + assert not (tmp_path / "run" / "experiment").exists() + + +def test_a_run_mixing_rgb_and_fluorescence_units_is_refused(tmp_path): + mixed = next(item for item in fixtures("valid") if item.name.startswith("v29_")) + with pytest.raises(ValueError, match="mix rgb and fluorescence"): + make_clean_run(tmp_path / "run", description=mixed.data) + + +def test_an_invalid_description_is_refused_with_the_readers_error(tmp_path): + with pytest.raises(Exception, match="schema"): + make_clean_run(tmp_path / "run", description=b'schema = "wrong"\n') + + +@pytest.mark.filterwarnings("ignore:No experiment description") +def test_make_clean_run_without_a_description_is_unchanged(tmp_path): + run = make_clean_run(tmp_path / "run", mask_types=("Cells",), design=True) + assert run.description is None and run.masks is not None + assert not (run.output / "morphobricks.toml").exists() + fovs = RunInputs.open(run.output).fovs() + assert {f.key[:3] for f in fovs} == {("ctrl", "rep1", "single"), ("drug", "rep1", "single")} + assert all(f.condition_id is None for f in fovs)