diff --git a/docs/contract.md b/docs/contract.md index b8393000..9ba34704 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -258,8 +258,8 @@ folder. | Confirmation | Per repeat group: "Were these images prepared and acquired under the same circumstances, so they are comparable with each other?". A group is confirmed when all its rows are. Without biological-repeat folders (every condition `single`), the whole experiment is one group and the question is asked once (N31). Missing technical-repeat folders need no question. | | Controls | None, one or several conditions; every row of a condition has the same value. Recorded only. | | Reruns | The file in the output folder is read and brought up to date with the folders found: folders in it keep their group, control mark and confirmation (added or removed images within a folder keep it too, with the counts recorded at confirmation); a new folder joins the group named after it and inherits its condition's control mark, and that group is asked again; folders no longer found are dropped without asking again. | -| Reading | `read_experiment_design` reads labels as text; `control` and `confirmed` accept `true`/`false` in any case, also `yes`/`no` and `1`/`0`, and empty as false; a byte-order mark is ignored. The user's edits are checked (owner decision of 31 October 2026): `repeat_group` follows the identity rules of §16 (D-e: not empty, `NA`, `nan`, `none`, `null`, `n/a` or `` in any case, no leading or trailing whitespace, and spelled one way: two groups differing only in case are refused) and `confirmed_on` is empty or a date `YYYY-MM-DD`; a bad edit raises `DesignFormatError` naming the line and column. `design_issues` gives the error `invalid_experiment_design` for such values in a design built in code, so a run stops before writing, and `write_experiment_design` refuses to write them. A missing column, an invalid value, a folder listed twice, rows of one condition disagreeing on `control` or another `schema_version` raise `DesignFormatError`. `ExperimentDesign.repeat_groups()` gives the `{(condition, biological_repeat): repeat_group}` mapping the normalization brick takes. This lenient reader is for the user's own edits; other bricks read the file with the strict `read_confirmed_design` (§16). | -| Notebook | Step 1 asks once the labels are confirmed and writes the file after every answer (unconfirmed groups with `confirmed=false`). A design confirmed for the same folders is loaded with the questions folded away behind **Change the experiment design**. A saved file that cannot be read (`DesignFormatError`, e.g. a `confirmed_on` a spreadsheet program rewrote as `26/10/2026`) is refused and left unchanged (owner decision of 31 October 2026, plan §12 question 42): step 1 shows the refusal, which names the line and column, starts the questions unanswered and writes nothing until the user corrects the file and confirms the labels again, or answers a question, which replaces the file. Nothing falls back to a default design silently, and step 3 does not run until every group is confirmed again. Step 3 runs only once every group is confirmed. A brick embedding step 1 with `folder_step(show_design=False)` (plan §12 question 31) gets no design: nothing is asked or written, and step 3 runs without one, as `run_experiment(design=None)`. | +| Reading | `read_experiment_design` reads labels as text; `control` and `confirmed` accept `true`/`false` in any case, also `yes`/`no` and `1`/`0`, and empty as false; a byte-order mark is ignored. The user's edits are checked (owner decision of 31 October 2026): `repeat_group` follows the identity rules of §16 (D-e: not empty, `NA`, `nan`, `none`, `null`, `n/a` or `` in any case, no leading or trailing whitespace, and spelled one way: two groups differing only in case are refused) and `confirmed_on` is empty or a date `YYYY-MM-DD`; a bad edit raises `DesignFormatError` naming the line and column. `design_issues` gives the error `invalid_experiment_design` for such values in a design built in code, so a run stops before writing, and `write_experiment_design` refuses to write them. A missing column, an invalid value, a folder listed twice, rows of one condition disagreeing on `control` or another `schema_version` raise `DesignFormatError`. `ExperimentDesign.repeat_groups()` gives the `{(condition, biological_repeat): repeat_group}` mapping the normalization brick takes. This lenient reader is for the user's own edits; other bricks read the file with the strict `read_confirmed_design` (§16). `load_design(output_folder, repeats)` reads the file with it and brings it up to date with the folders (Reruns), returning `(design, changes)`, the default design when there is no file; a file that cannot be read raises `DesignCheckError` (`unreadable_experiment_design`, §16) and is left as it is (owner decision of 31 October 2026, plan §12 question 43). | +| Notebook | Step 1 asks once the labels are confirmed and writes the file after every answer (unconfirmed groups with `confirmed=false`). A design confirmed for the same folders is loaded with the questions folded away behind **Change the experiment design**. A saved file that cannot be read (`load_design` raises `DesignCheckError`, `unreadable_experiment_design`, e.g. for a `confirmed_on` a spreadsheet program rewrote as `26/10/2026`) is refused and left unchanged (owner decision of 31 October 2026, plan §12 question 42): step 1 shows the refusal, which names the line and column, starts the questions unanswered and writes nothing until the user corrects the file and confirms the labels again, or answers a question, which replaces the file. Nothing falls back to a default design silently, and step 3 does not run until every group is confirmed again. Step 3 runs only once every group is confirmed. A brick embedding step 1 with `folder_step(show_design=False)` (plan §12 question 31) gets no design: nothing is asked or written, and step 3 runs without one, as `run_experiment(design=None)`. | | Command line | `run` reads the file from the output folder, or `--design FILE`. Without a fully confirmed design it writes the design brought up to date with the folders (a template with `confirmed=false` for the groups still to confirm, keeping the answers already given) to the output folder, explains the columns, and stops with status 1. Confirmed rows without a date get the date of the run. | | Run | `run_experiment(..., design=...)` brings the design up to date with the folders and stops with `design_not_confirmed` or `repeat_group_conflict` if it is not fully confirmed. Otherwise the file is written with the table and published before the manifest, which records its SHA-256 (§9). Without a design the run is unchanged: no file is written, an existing one is left as it is, and `experiment_design_sha256` is null. `plane_histograms.csv` does not depend on the design. | @@ -413,5 +413,5 @@ instead of keeping copies. | Sources of a run | `check_sources(manifest, experiment_root, relative_paths, image_axes=, identities=)`, `source_fingerprint`, `file_sha256` (`manifest`) | The checks a brick makes before using a run's table for some images (formerly CellPatcher `check_images` and MorphoFeature `histogram_fit`). Each image must be in the manifest (`images_not_in_manifest`), still exist (`images_missing`), and be unchanged by `source_fingerprint`, its size and modification time, for an OME-Zarr folder its files' total size and newest time, so a rewritten chunk counts (`images_changed`). Its recorded `position_id` must be the one its name gives; with `identities` (`FOV_KEY` order), its labels and `position_id` must be the caller's (`identity_differs`; `position_id_not_recorded` for a manifest written before positions were recorded). With `image_axes`, the caller's axes must give each axis the role recorded for the file (`FileEntry.roles`, or the run's mapping resolved for the header in older manifests); an axis without a role (length 1) fits any letter (`axis_mapping_mismatch`). All errors, naming their images; the table's own hash is checked with `file_sha256` against `csv_sha256`. | | Masks named as images (D-i) | `mask_name_issues(leaves, mask_names=())`, `images_named_as_masks(leaves, suffixes=MASK_NAME_SUFFIXES)`, `mask_folder_part(path)`, `MASK_NAME_SUFFIXES`, `MASK_FOLDER_WORDS` (`discovery`) | The screen MorphoRelate's `init` ran (`project_image_names`), now discovery's warnings `image_named_as_mask` and `mask_folder_in_experiment` (§1) for every brick. Names are compared in lower case across the whole experiment; the longest `position_id` owns a file, an image of the same folder first. `mask_names` adds the caller's expected mask names (`cells` for `f1_cells.tif`) to `MASK_NAME_SUFFIXES`; `discover_experiment` gives none. A folder counts as a masks folder when a word of its name (`CellLabels` is `Cell` and `Labels`) is in `MASK_FOLDER_WORDS`. `images_named_as_masks` returns the `(image, owner)` pairs of that detection for the caller's own name endings (`["_cells"]`), for a brick that acts on the files itself (MorphoRelate leaves out the images named with a declared mask name). | | Pixel sizes (D-a) | `same_pixel_size(a, b, roles=("X", "Y"))`, `pixel_size_groups(sizes, roles=)`, `PIXEL_SIZE_TOLERANCE` (`physical_sizes`) | Two pixel sizes are the same when `|a - b| <= 1e-3 * max(a, b)` on every compared axis, in micrometres; one rule for a declared size against a header and for two headers. `same_pixel_size` takes `PhysicalSizes` (converted with `micrometers`) or sizes already in micrometres, one per role, and returns None when either is unknown (no size, or a unit that cannot be converted), never True. The rule is not transitive (1.0 ≈ 1.0009 ≈ 1.0018, but 1.0 ≠ 1.0018), so there is no per-image grouping key: `pixel_size_groups` links images by single linkage, so images of two groups always differ while the extremes of one group may drift; unknown sizes are left out; the largest group first. The folder check's `different_pixel_sizes` uses both (§11). | -| Experiment design | `read_confirmed_design(path, expected_sha256=)`, `ConfirmedDesign`, `DesignCheckError` (`design`) | The one reader of `experiment_design.csv` (§12) for a brick that pools biological repeats (formerly CellPatcher `load_experiment_design` and MorphoFeature `ExperimentDesign.read`). It takes the file as this brick writes it and fills nothing in: UTF-8 without a byte-order mark; the header exactly the §12 columns in order; one value per column in every row (an unclosed quote, a missing or extra value, or a blank line is refused); no value with outer whitespace; `control` and `confirmed` exactly `true` or `false`; counts written as whole numbers of at least 0; `schema_version` 1; `confirmed_on` empty or `YYYY-MM-DD`; `condition`, `biological_repeat` and `repeat_group` pass `check_identity_text` and do not differ only in case (`check_identity_case`); no folder twice, one `control` per condition, at least one row; every group confirmed with one folder per condition (`design_issues`); with `expected_sha256` (the manifest's `experiment_design_sha256`) the file must be that one. The SHA-256 is of the bytes parsed. `ConfirmedDesign` gives `path`, `sha256`, the `design`, read-only `repeat_groups` (`{(condition, biological_repeat): repeat_group}`, the normalization brick's input), `group_members()` (`{repeat_group: {condition: biological_repeat}}`), `conditions`, `controls` and `groups`. Refusals raise `DesignCheckError` (a `ValueError`) with its `issues`: `missing_experiment_design`, `unreadable_experiment_design`, `invalid_experiment_design`, `repeat_group_conflict`, `design_not_confirmed` or `changed_experiment_design`, each an error naming the file (or the group's folders). | +| Experiment design | `read_confirmed_design(path, expected_sha256=)`, `ConfirmedDesign`, `DesignCheckError` (`design`) | The one reader of `experiment_design.csv` (§12) for a brick that pools biological repeats (formerly CellPatcher `load_experiment_design` and MorphoFeature `ExperimentDesign.read`). It takes the file as this brick writes it and fills nothing in: UTF-8 without a byte-order mark; the header exactly the §12 columns in order; one value per column in every row (an unclosed quote, a missing or extra value, or a blank line is refused); no value with outer whitespace; `control` and `confirmed` exactly `true` or `false`; counts written as whole numbers of at least 0; `schema_version` 1; `confirmed_on` empty or `YYYY-MM-DD`; `condition`, `biological_repeat` and `repeat_group` pass `check_identity_text` and do not differ only in case (`check_identity_case`); no folder twice, one `control` per condition, at least one row; every group confirmed with one folder per condition (`design_issues`); with `expected_sha256` (the manifest's `experiment_design_sha256`) the file must be that one. The SHA-256 is of the bytes parsed. `ConfirmedDesign` gives `path`, `sha256`, the `design`, read-only `repeat_groups` (`{(condition, biological_repeat): repeat_group}`, the normalization brick's input), `group_members()` (`{repeat_group: {condition: biological_repeat}}`), `conditions`, `controls` and `groups`. Refusals raise `DesignCheckError` (a `ValueError`) with its `issues`: `missing_experiment_design`, `unreadable_experiment_design`, `invalid_experiment_design`, `repeat_group_conflict`, `design_not_confirmed` or `changed_experiment_design`, each an error naming the file (or the group's folders). This brick's step 1 and `check` read the file being edited with the lenient `load_design(output_folder, repeats)` (§12), which returns `(design, changes)`: the saved design brought up to date with the folders, or the default design when there is no file. A saved file that cannot be read raises `DesignCheckError` with the one error `unreadable_experiment_design`, whose message is the same "The experiment design cannot be read: …" as here followed by the lenient reader's refusal, naming the line and column where it can (the `DesignFormatError` is its `__cause__`); no default design is returned and the file is not touched (owner decision of 31 October 2026, plan §12 question 43). **Breaking change for the next release:** up to v0.3.4 `load_design` returned a third value, `problem`, the reason as text, together with the default design. | diff --git a/docs/plan.md b/docs/plan.md index c4978cac..df795a45 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1001,3 +1001,15 @@ Raised during review; to be decided before the milestones that need them: again, which replaces the file. No silent fallback to a default design; step 3 runs only once every group is confirmed again. The command line already refused such a file without writing it.* +43. What does the public `load_design()` do with a saved `experiment_design.csv` it cannot + read (PR #61 follow-up)? Until now it returned the default design together with the + reason as a `problem` string, so a caller that ignored the string worked on a design + nobody confirmed. + *Decided (31 October 2026): it raises `DesignCheckError` with the one error + `unreadable_experiment_design` (the code and the message "The experiment design cannot + be read: …" of `read_confirmed_design`, followed by the lenient reader's refusal naming + the line and column; the `DesignFormatError` is its `__cause__`), and returns no + default design. It now returns `(design, changes)`. Without a file it still returns the + default design: no file is the first run, not an unreadable one. The notebook keeps the + behaviour of question 42 (file untouched, refusal shown, an answer replaces it), and + `check` prints the refusal and writes nothing. Breaking change for the next release.* diff --git a/docs/user_guide.md b/docs/user_guide.md index 7933b4b8..a268f4d2 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -387,7 +387,8 @@ added, it writes that file and stops, so you can fill it in with a spreadsheet p text editor: give rows the same `repeat_group` to pair biological repeats named differently, set `confirmed` to `true` in every row of a repeat group whose images are comparable, and optionally `control` to `true` for control conditions. Then run again. `check` says whether -the design is confirmed. Other options: +the design is confirmed, or why a saved one cannot be read (naming the line and column); it +never writes the file. Other options: | Option | Meaning | |---|---| @@ -580,6 +581,7 @@ continues. | `repeat_group_conflict` | Error | A repeat group holds two folders of the same condition. Move one into another group. | | `invalid_experiment_design` | Error | A repeat group has no usable name (empty, `NA`, `nan`, `none`, `null`, `n/a`, ``, or spaces around it), two repeat groups are spelled the same apart from upper and lower case, or a confirmation date is not written `YYYY-MM-DD`. Correct `experiment_design.csv` (the message names the line and column). | | `missing_experiment_design`, `unreadable_experiment_design` | Error (other bricks) | A brick that pools biological repeats as `experiment_design.csv` pairs them found no such file, or one it cannot use as written by the preprocessing brick (another column order, a value with spaces around it, `TRUE` or `yes` for `true`, an empty repeat group, a byte-order mark from a spreadsheet program, ...). Run the preprocessing brick on the experiment again: its run writes the file as other bricks read it. | +| `unreadable_experiment_design` | Error (`load_design`, notebook step 1, `check`) | The saved `experiment_design.csv` in the output folder cannot be read even leniently (for example a date a spreadsheet program rewrote as 26/10/2026, or a repeat group `NA`); the message names the line and column. The file is left as it is. Correct it, or answer the questions again in step 1, which replaces it. | | `changed_experiment_design` | Error (other bricks) | `experiment_design.csv` is not the file the preprocessing run that wrote the statistics used (its SHA-256 differs from the one in the run manifest). Give that run's design, or run the preprocessing brick again. | | `image_intensity_outlier` | Warning (intensity check) | An image's median or high percentile in a channel is far from the other images of its condition and biological repeat. Look at it: it may be out of focus, contain debris or bubbles, or have been acquired differently. Exclude it if it is faulty. | | `repeat_intensity_shift` | Warning (intensity check) | A biological repeat is brighter or darker than the other repeats of its condition. Check whether it was prepared or acquired differently; normalising per biological repeat in later bricks removes such differences. | @@ -621,14 +623,18 @@ records = read_plane_histograms(result.csv_path) ``` The experiment design is optional in Python: without `design=`, the run neither asks for -nor writes one. To record it as the notebook does: +nor writes one. To record it as the notebook does (`load_design` returns the saved design +brought up to date with the folders, or the default design when there is no file yet; a +saved file that cannot be read raises `DesignCheckError` instead, never a default design; +breaking change for the next release, plan §12 question 43: until v0.3.4 it returned a +third value, `problem`, with the default design): ```python from morphobricks_preprocessing import load_design -design, changes, problem = load_design(result.output_folder, check.dataset.repeats) -if problem: # an existing file that cannot be read: correct it, do not overwrite it - raise SystemExit(problem) +# An existing file that cannot be read raises DesignCheckError (unreadable_experiment_design, +# naming the line and column) and is left as it is: correct it, do not overwrite it. +design, changes = load_design(result.output_folder, check.dataset.repeats) design = design.with_group(("treated", "rep2_day2"), "rep2") # pair a repeat for group in design.groups: design = design.with_confirmed(group, True, "2026-10-26") diff --git a/src/morphobricks_preprocessing/cli.py b/src/morphobricks_preprocessing/cli.py index ccf03962..7c7f9a49 100644 --- a/src/morphobricks_preprocessing/cli.py +++ b/src/morphobricks_preprocessing/cli.py @@ -36,6 +36,7 @@ from .axes import AxisMapping, reference_axes, review_axes from .design import ( DESIGN_FILENAME, + DesignCheckError, DesignFormatError, ExperimentDesign, design_issues, @@ -345,10 +346,15 @@ def _check(args: argparse.Namespace) -> int: print(format_issues(pre.issues)) if pre.dataset.repeats: output = Path(args.output) if args.output else default_output_folder(pre.discovery.root) - design, _, problem = load_design(output, pre.dataset.repeats) + try: + design, _ = load_design(output, pre.dataset.repeats) + except DesignCheckError as exc: + # Refused and left as it is: 'check' never writes the design. + design, refusal = None, exc.issues[0] print() - if problem: - print(f"Experiment design: {problem}") + if design is None: + print(f"Experiment design: {refusal.message}") + print(f" Suggested action: {refusal.action}") elif (output / DESIGN_FILENAME).exists(): print(f"Experiment design ({output / DESIGN_FILENAME}):") print(design_summary(design)) diff --git a/src/morphobricks_preprocessing/design.py b/src/morphobricks_preprocessing/design.py index 3577d91e..6a8178fc 100644 --- a/src/morphobricks_preprocessing/design.py +++ b/src/morphobricks_preprocessing/design.py @@ -63,6 +63,9 @@ class DesignFormatError(ValueError): class DesignCheckError(ValueError): """Raised by :func:`read_confirmed_design` when a design cannot be used. + Also raised by :func:`load_design`, with the one error + ``unreadable_experiment_design``, when a saved file cannot be read. + Attributes ---------- issues @@ -618,16 +621,7 @@ def read_confirmed_design( data = path.read_bytes() design = _parse_strict(data, path.name) except (OSError, DesignFormatError) as exc: - raise DesignCheckError( - [ - error( - "unreadable_experiment_design", - f"The experiment design cannot be read: {exc}", - [str(path)], - rerun, - ) - ] - ) from exc + raise _unreadable(path, exc, rerun) from exc issues = design_issues(design) if issues: raise DesignCheckError(issues) @@ -706,25 +700,64 @@ def write_experiment_design(design: ExperimentDesign, path: str | os.PathLike) - def load_design( output_folder: str | os.PathLike, repeats: Iterable[BiologicalRepeat] -) -> tuple[ExperimentDesign, DesignChanges, str | None]: +) -> tuple[ExperimentDesign, DesignChanges]: """Read the design in an output folder, if any, and bring it up to date with the folders. + The file is read with the lenient :func:`read_experiment_design`. A file that + cannot be read is refused, never replaced by the default design (owner decision of + 31 October 2026, plan §12 question 43): the file is left as it is. + + Parameters + ---------- + output_folder + The output folder that holds ``experiment_design.csv``, if it was written. + repeats + The biological-repeat folders found now + (:func:`.dataset.biological_repeats`). + Returns ------- - tuple - The design (the default one when there is no readable file), how the - folders changed since it was written, and why an existing file could - not be read (None when it could, or when there is none). + tuple of ExperimentDesign and DesignChanges + The saved design brought up to date with the folders (:func:`reconcile_design`), + or the default design when there is no file yet, and how the folders changed + since it was written. + + Raises + ------ + DesignCheckError + With the one error ``unreadable_experiment_design`` when a saved file cannot be + read; its message is :func:`read_experiment_design`'s refusal, naming the line and + column where it can. The :class:`DesignFormatError` is its ``__cause__``. """ path = Path(output_folder) / DESIGN_FILENAME - previous, problem = None, None + previous = None if path.exists(): try: previous = read_experiment_design(path) except DesignFormatError as exc: - problem = str(exc) - design, changes = reconcile_design(previous, repeats) - return design, changes, problem + raise _unreadable( + path, + exc, + "Correct the file where the message says (a spreadsheet program may have " + f"rewritten a date such as 2026-10-26 as 26/10/2026; dates are {DATE_FORMAT}). " + "Answering the questions again in step 1 of the notebook also replaces it; " + "nothing replaces it without an answer.", + ) from exc + return reconcile_design(previous, repeats) + + +def _unreadable(path: Path, exc: Exception, action: str) -> DesignCheckError: + """Return the refusal ``unreadable_experiment_design`` of a design file.""" + return DesignCheckError( + [ + error( + "unreadable_experiment_design", + f"The experiment design cannot be read: {exc}", + [str(path)], + action, + ) + ] + ) def _parse_strict(data: bytes, name: str) -> ExperimentDesign: diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index f3de3ab7..6e11ad6f 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -73,6 +73,7 @@ from .design import ( DESIGN_FILENAME, DesignChanges, + DesignCheckError, ExperimentDesign, default_design, load_design, @@ -777,8 +778,12 @@ def _open_design(self) -> None: if output is None: design = default_design(repeats) else: - design, changes, problem = load_design(output, repeats) - saved = problem is None and (output / DESIGN_FILENAME).exists() + try: + design, changes = load_design(output, repeats) + saved = (output / DESIGN_FILENAME).exists() + except DesignCheckError as exc: + # Refused and left as it is; the questions start unanswered. + design, problem = default_design(repeats), exc.issues[0].message self.design = design self._design_unreadable = problem is not None self._build_design_editor() diff --git a/tests/test_design.py b/tests/test_design.py index b0b97119..a6eeff6c 100644 --- a/tests/test_design.py +++ b/tests/test_design.py @@ -2,12 +2,14 @@ from morphobricks_preprocessing import ( DESIGN_FILENAME, + DesignCheckError, DesignFormatError, biological_repeats, default_design, default_output_folder, design_issues, discover_experiment, + load_design, preflight, read_experiment_design, read_manifest, @@ -449,6 +451,77 @@ def test_cli_check_reports_the_design(tmp_path, capsys): assert "Controls: none marked" in out +def test_cli_check_refuses_an_unreadable_design_without_writing(tmp_path, capsys): + root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1}) + path = default_output_folder(root) / DESIGN_FILENAME + write_experiment_design(confirm_all(default_design(repeats_of(root))), path) + path.write_text(path.read_text(encoding="utf-8").replace(TODAY, "26/10/2026"), "utf-8") + unreadable = path.read_bytes() + assert main(["check", str(root)]) == 0 + out = capsys.readouterr().out + assert "Experiment design: The experiment design cannot be read" in out + assert "line 2, column confirmed_on" in out + assert path.read_bytes() == unreadable + + +# load_design (owner decision of 31 October 2026, plan §12 question 43) ------------------ + + +def test_load_design_without_a_file_gives_the_default_design(tmp_path): + root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1}) + design, changes = load_design(tmp_path / "out", repeats_of(root)) + assert design == default_design(repeats_of(root)) + assert not changes.any + assert not (tmp_path / "out").exists() + + +def test_load_design_reads_and_reconciles_a_saved_design(tmp_path): + root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1}) + saved = confirm_all(default_design(repeats_of(root))).with_control("control", True) + write_experiment_design(saved, tmp_path / "out" / DESIGN_FILENAME) + make_tree(root, ["treated/rep2/img_00.tif"]) + design, changes = load_design(tmp_path / "out", repeats_of(root)) + assert design.group_confirmed("rep1") and design.controls == ("control",) + assert changes.added == (("treated", "rep2"),) + + +@pytest.mark.parametrize( + ("edit", "where"), + [ + (lambda text: text.replace(TODAY, "26/10/2026"), "line 2, column confirmed_on"), + (lambda text: text.replace("rep1,rep1,", "rep1,NA,", 1), "line 2, column repeat_group"), + (lambda text: "not a design", "column(s)"), + ], +) +def test_load_design_raises_for_an_unreadable_file(tmp_path, edit, where): + # Owner decision of 31 October 2026: no default design and no problem string; the + # refusal names the line and column, and the file is left as it is. + root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1}) + path = tmp_path / "out" / DESIGN_FILENAME + write_experiment_design(confirm_all(default_design(repeats_of(root))), path) + path.write_text(edit(path.read_text(encoding="utf-8")), encoding="utf-8") + unreadable = path.read_bytes() + with pytest.raises(DesignCheckError) as caught: + load_design(tmp_path / "out", repeats_of(root)) + (issue,) = caught.value.issues + assert issue.code == "unreadable_experiment_design" + assert issue.is_error + assert issue.message.startswith("The experiment design cannot be read: ") + assert where in issue.message + assert issue.paths == (str(path),) + assert issue.action + assert isinstance(caught.value.__cause__, DesignFormatError) + assert isinstance(caught.value, ValueError) + assert path.read_bytes() == unreadable + + +def test_load_design_raises_when_the_design_path_is_a_folder(tmp_path): + root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1}) + (tmp_path / "out" / DESIGN_FILENAME).mkdir(parents=True) + with pytest.raises(DesignCheckError, match="unreadable_experiment_design"): + load_design(tmp_path / "out", repeats_of(root)) + + def test_preflight_repeats_feed_the_design(tmp_path): root = tree(tmp_path / "exp", PAIRED) assert preflight(root).dataset.repeats == tuple(repeats_of(root))