From f9793f0e7b76c56dbb8a9d0cc694e88d99fd628e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:38:30 +0000 Subject: [PATCH 1/2] Spread a member's image_count over its technical repeats in make_described_run image_count is the member total over its units, but the helper wrote it into each technical repeat (v34 CTRL/rep1: 24 images instead of 12; DrugA/rep1: 22 instead of 11). Distribute exactly image_count images, earlier units by sorted ID taking the remainder, and refuse (ValueError naming the member) when image_count < technical repeats. Fix the test whose _expected() repeated the bug; add v34 and refusal regressions. Fixes #234 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm --- .../testing_described.py | 34 ++++++++++++++---- tests/test_make_described_run.py | 36 +++++++++++++++++-- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/morphobricks_preprocessing/testing_described.py b/src/morphobricks_preprocessing/testing_described.py index 5315ae95..08261b2a 100644 --- a/src/morphobricks_preprocessing/testing_described.py +++ b/src/morphobricks_preprocessing/testing_described.py @@ -2,9 +2,9 @@ :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, +technical repeat, ``image_count`` tiny images per member (its total, spread over its +technical repeats; each image has as many channels as the unit declares, or is 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. @@ -87,12 +87,31 @@ def unit_folder(description: Description, repeat_id: str, condition_id: str, uni def _units(description: Description) -> Iterator[tuple[Path, Unit, int]]: - """Yield ``(folder, unit, image count)`` of every declared unit.""" + """Yield ``(folder, unit, image count)`` of every declared unit. + + A member's ``image_count`` is its total over its technical repeats, so it is spread + over them as evenly as possible: every unit gets ``image_count // n`` images and the + first ``image_count % n`` units, by sorted unit ID, one more. + + Raises + ------ + ValueError + If a member has fewer images than technical repeats (a unit would be empty). + """ for repeat_id, repeat in description.repeats.items(): for condition_id, member in repeat.members.items(): - for unit in member.technical_repeats.values(): + unit_ids = sorted(member.technical_repeats) + base, extra = divmod(member.image_count, len(unit_ids)) + if base == 0: + raise ValueError( + f"member {condition_id!r} of repeat {repeat_id!r} has image_count " + f"{member.image_count} for {len(unit_ids)} technical repeats; every " + "technical repeat needs at least one image" + ) + for position, unit_id in enumerate(unit_ids): + unit = member.technical_repeats[unit_id] folder = unit_folder(description, repeat_id, condition_id, unit) - yield folder, unit, member.image_count + yield folder, unit, base + (1 if position < extra else 0) def _mapping(description: Description) -> AxisMapping: @@ -186,7 +205,8 @@ def make_described_run( Raises ------ ValueError - For an unknown mask ID, or a description that mixes rgb and fluorescence units. + For an unknown mask ID, a description that mixes rgb and fluorescence units, or a + member whose ``image_count`` is below its number of technical repeats. RuntimeError If the run or the mask step does not write its outputs (its issues are in the message). """ diff --git a/tests/test_make_described_run.py b/tests/test_make_described_run.py index 9eaf9220..0e42715d 100644 --- a/tests/test_make_described_run.py +++ b/tests/test_make_described_run.py @@ -27,8 +27,13 @@ def _expected(description): 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 + # image_count is the member's total: earlier units (sorted ID) take the remainder. + base, extra = divmod(member.image_count, len(member.technical_repeats)) + for position, unit_id in enumerate(sorted(member.technical_repeats)): + unit = member.technical_repeats[unit_id] + found[(condition_id, repeat_id, unit_id, unit.channels)] += base + ( + position < extra + ) return found @@ -49,6 +54,33 @@ def test_each_fixture_builds_opens_and_gives_its_canonical_ids(tmp_path, name): assert len({f.key for f in fovs}) == len(fovs) +def test_v34_member_image_count_is_the_total_over_its_technical_repeats(tmp_path): + description = description_from_bytes(VALID["v34"].data) + run = make_clean_run(tmp_path / "run", description=VALID["v34"].data) + fovs = RunInputs.open(run.output).fovs() + per_member = Counter((f.condition_id, f.repeat_group) for f in fovs) + per_unit = Counter((f.condition_id, f.repeat_group, f.technical_repeat_id) for f in fovs) + # Counts read straight from the parsed description (v34: CTRL/rep1 12, DrugA/rep1 11). + assert description.repeats["rep1"].members["CTRL"].image_count == 12 + assert description.repeats["rep1"].members["DrugA"].image_count == 11 + assert per_member[("CTRL", "rep1")] == 12 + assert per_member[("DrugA", "rep1")] == 11 + assert per_unit[("CTRL", "rep1", "acq1")] == 6 and per_unit[("CTRL", "rep1", "acq2")] == 6 + assert per_unit[("DrugA", "rep1", "acq1")] == 6 and per_unit[("DrugA", "rep1", "acq2")] == 5 + for repeat_id, repeat in description.repeats.items(): + for condition_id, member in repeat.members.items(): + assert per_member[(condition_id, repeat_id)] == member.image_count + assert {f.technical_repeat_id for f in fovs if f.repeat_group == "rep1"} == {"acq1", "acq2"} + assert sum(per_member.values()) == len(fovs) == 43 + + +def test_a_member_with_fewer_images_than_technical_repeats_is_refused(tmp_path): + data = VALID["v34"].data.decode().replace("image_count = 11", "image_count = 1") + with pytest.raises(ValueError, match=r"'DrugA'.*image_count 1 for 2 technical repeats"): + make_described_run(tmp_path / "run", data) + assert not (tmp_path / "run" / "experiment").exists() + + 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) From 562ee3bf3845b9cb54f1546c087574ba82bf898b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 05:06:30 +0000 Subject: [PATCH 2/2] Wrap a docstring line over 100 characters (lint) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- src/morphobricks_preprocessing/testing_described.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/morphobricks_preprocessing/testing_described.py b/src/morphobricks_preprocessing/testing_described.py index 08261b2a..57272da7 100644 --- a/src/morphobricks_preprocessing/testing_described.py +++ b/src/morphobricks_preprocessing/testing_described.py @@ -4,7 +4,8 @@ (``morphobricks.toml``, experiment-v1) declares: one folder per condition, repeat and technical repeat, ``image_count`` tiny images per member (its total, spread over its technical repeats; each image has as many channels as the unit declares, or is 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, +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.