diff --git a/src/morphobricks_preprocessing/testing_described.py b/src/morphobricks_preprocessing/testing_described.py index 5315ae95..57272da7 100644 --- a/src/morphobricks_preprocessing/testing_described.py +++ b/src/morphobricks_preprocessing/testing_described.py @@ -2,9 +2,10 @@ :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 +88,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 +206,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)