Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions src/morphobricks_preprocessing/testing_described.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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).
"""
Expand Down
36 changes: 34 additions & 2 deletions tests/test_make_described_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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)
Expand Down
Loading