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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ src/morphobricks_preprocessing/
header consistency warnings (width/height, plane counts, pixel sizes);
header_axes and named_axes (the axes the metadata names, contract §16)
physical_sizes.py PhysicalSizes (pixel sizes, z-step, time step, a unit per axis, micrometers()
for comparing), space_unit/to_micrometers (the shared unit rule, contract §16),
for comparing), re-exports length_units' space_unit/to_micrometers,
read_physical_sizes (OME / ImageJ TIFF metadata; also importable
) and ngff_physical_sizes (OME-NGFF scale and axis units)
length_units.py space_unit/to_micrometers, SPACE_UNITS (the shared unit rule, contract
§16): a conversion is always a positive finite length, else ValueError (#93)
ngff_scale.py read_scale: an OME-NGFF coordinateTransformations scale, checked; what
is wrong with a malformed one (PhysicalSizes.invalid, issue #94)
dataset.py dataset consistency from the folders: biological repeats per condition, balance
Expand Down
11 changes: 6 additions & 5 deletions docs/contract.md

Large diffs are not rendered by default.

36 changes: 35 additions & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,13 @@ API, CLI). The notebook and CLI only call the API.
65)*: `links.LinkGuard` follows a link only inside the folder walked (discovery, OME-Zarr
stores, the masks folder; `RunInputs` and `convert` refuse the same links), and the CLI
checks `--log` (`log_target`) before opening it, after the other arguments.
- **Before the audit: micrometre conversion and exit status (issue #93, #130 review)**
*(this PR; question 66)*: every conversion to micrometres is a positive finite length
(`length_units.to_micrometers`, moved from `physical_sizes` with the unit table, which
re-exports them); a header size that overflows or underflows is unknown and named by
`invalid_physical_calibration`, a stated one is refused, and an infinite size is never
the same as another; `check` and `design` exit with status 1 whenever they print an
error.
- **Release v0.4.0 (PP-13) checklist**, for every tag of v0.4.0 including release
candidates, in the release PR before the tag is pushed:
- set `version` in `pyproject.toml`;
Expand Down Expand Up @@ -1619,4 +1626,31 @@ Raised during review; to be decided before the milestones that need them:
case (macOS and Windows), and an existing file with a second name (a hard link) is
refused. Also in review: the design file given with `--design` (an input) is refused
(it was appended to). Contract §8; user guide §5.

66. What happens to a size that does not fit in micrometres, and what is the exit status of
a command that reports an error (issue #93, review of #130)?
*Decided, pending the owner's confirmation (2 November 2026, design v0.4 §3: nothing
guessed, nothing silent; the coordinator's answers in the review of #133, recorded for
the owner to confirm: a header size that overflows or cannot be converted is a
warning, the image uncalibrated with the reason, as the owner decided for #94; and
`design` exiting with 1 after a write that leaves a conflict is accepted):* (1) every conversion to micrometres goes through
`to_micrometers`, whose result is always a positive finite number: a length that
overflows (`1e308 m` is infinite in micrometres) or underflows (`1e-320 pm` is zero), or
a whole number beyond the float range, is a `ValueError` naming the value and unit
(before, `inf` and `0.0` were returned, and an `OverflowError` raised).
`PhysicalSizes.micrometers` uses it and is None for such a size, so `same_pixel_size`
is None and `pixel_size_groups` leaves it out (both raised `ValueError` before), and
the comparison never takes an infinite or NaN size for another (before, `inf` was "the
same" as every size, so `1e308 m` beside `0.5 µm` gave no issue at all). (2) A header
size of TIFF or OME-Zarr metadata that does so, or an OME-Zarr level-0 scale times the
image's scale that does so, or a TIFF OME or ImageJ size that is itself not a positive
finite number (`inf`, `NaN`, zero, negative or not a number, before silently unknown;
review of #133), is treated like the malformed calibrations of question 60
(3): the size is None, `PhysicalSizes.invalid` gives the axis and value, and the warning
`invalid_physical_calibration` names the image (a warning, not an error, as for #94);
a stated size in `calibration.csv` or `calibrate` is refused naming the line. (3) Every
command exits with status 1 when it prints an error, and 0 otherwise (warnings never
change it): `check` exited with 0 on an experiment design it could not read or that
had `repeat_group_conflict` or `invalid_experiment_design` (which `run` stops on;
review of #133), and `design` with 0 when the design it printed had `repeat_group_conflict` or
`invalid_experiment_design`, also after writing a change. `run`, `masks`, `calibrate`
and `convert` already did. Contract §2, §9.2, §9.3, §11, §16; user guide §5, §8.
15 changes: 12 additions & 3 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,8 +620,17 @@ report names the change and its effect. Other options:

After writing, `run` prints the intensity checks of the table, grouped by kind, each with
the channel, the image or biological repeat and the values compared, and what to do once
per kind; it draws no plots (the notebook does). Both commands exit with status 1 when an
error blocks processing; intensity warnings never do.
per kind; it draws no plots (the notebook does).

**Exit status.** Every command (`check`, `run`, `masks`, `calibrate`, `design` and
`convert`) exits with status 1 when it prints an error, and with 0 otherwise: warnings,
intensity warnings included, never change it. So `check` exits with 1 on every error it
reports, also an experiment design it cannot read (it never replaces that file) or one
with a `repeat_group_conflict` or `invalid_experiment_design`, which `run` stops on; and
`design` exits with 1 when the design it prints has a `repeat_group_conflict` or
`invalid_experiment_design`, also after a change it wrote (since v0.4.0; both exited with 0
before). Options that cannot be read exit with 2. Scripts can therefore stop on any
non-zero status.

The optional `masks` command reads the masks and tells how they relate:

Expand Down Expand Up @@ -973,7 +982,7 @@ continues.
| `different_pixel_sizes` | Warning | The physical pixel size or z-step (from the metadata or `calibration.csv`) differs between images by more than 0.1 % (for example different objectives). Images outside the largest group of matching sizes are listed; when sizes only change by small steps from image to image, none is listed. Check the microscope settings if this is not intended. |
| `missing_pixel_size` | Warning | Some images have a pixel size (from the metadata or `calibration.csv`) and others do not, so it cannot be checked; images stated as measured in pixels count as without one, and the message says how many. Export them again with metadata, or state their sizes with `calibrate`, if needed. |
| `unsupported_pixel_size_unit` | Warning | An image gives its pixel size or z-step in a unit that is not converted (for example inches); only pm, Å, nm, µm, mm, cm and m are. The message names each image, axis and unit. Those sizes are not used: the image is treated as uncalibrated on those axes. Export it again with the pixel size in a supported unit, or state it with `calibrate`, if the physical size matters. Not reported for an axis whose size is stated. |
| `invalid_physical_calibration` | Warning | An image's metadata give a pixel size, z-step or time step that cannot be used (for OME-Zarr: `coordinateTransformations` that are malformed, of an unsupported type, stored in a separate file, or a scale that is zero, negative or not a number); the message names each image and what is wrong. The image is treated as uncalibrated on those axes; no size is made up. Correct the metadata, or state the sizes with the `calibrate` command: the warning goes once every lost pixel size and z-step is stated. A lost time step cannot be stated, so its warning stays until the metadata are corrected. |
| `invalid_physical_calibration` | Warning | An image's metadata give a pixel size, z-step or time step that cannot be used (for OME-Zarr: `coordinateTransformations` that are malformed, of an unsupported type, stored in a separate file, or a scale that is zero, negative or not a number; for any image: a size too large or too small to be written in micrometres, such as `1e308 m`); the message names each image and what is wrong, with the value. The image is treated as uncalibrated on those axes; no size is made up. Correct the metadata, or state the sizes with the `calibrate` command: the warning goes once every lost pixel size and z-step is stated. A lost time step cannot be stated, so its warning stays until the metadata are corrected. |
| `calibration_axis_absent` | Warning | `calibration.csv` states a size for an axis some images do not have (for example a Z size for 2D images or projections); the message names them. No size is recorded for an axis an image does not have. Give the statement a narrower target, or no action if it is meant for the other images. |
| `calibration_differs_from_header` | Warning | `calibration.csv` states a pixel size (or `pixels`) that differs from an image's metadata; the message names each image and axis with both sizes. The stated size is used. Correct or remove the statement if the metadata are right. |
| `calibration_target_not_found` | Error | `calibration.csv` names a folder or image that is not in the experiment (names are compared exactly, case included). Correct the target, or remove the line. |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.notebook_style" = "ui"
"morphobricks_preprocessing.notebook_ui" = "ui"
"morphobricks_preprocessing.physical_sizes" = "io"
"morphobricks_preprocessing.length_units" = "pure"
"morphobricks_preprocessing.pixel_size_checks" = "pure"
"morphobricks_preprocessing.ngff_scale" = "pure"
"morphobricks_preprocessing.calibration" = "pure"
Expand Down
10 changes: 7 additions & 3 deletions src/morphobricks_preprocessing/calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ def calibration_entry(target: str, axis: str, size: object, unit: str) -> Calibr
``"X"``, ``"Y"`` or ``"Z"`` (upper case).
size
A positive, finite number (or its text in plain decimal notation, such as ``"0.65"``
or ``"1e-3"``), or None/``""`` with the unit ``"pixels"``.
or ``"1e-3"``) that is also one in micrometres (issue #93), or None/``""`` with the
unit ``"pixels"``.
unit
A supported length unit in any spelling :func:`.physical_sizes.space_unit` reads,
or ``"pixels"``.
Expand All @@ -138,8 +139,9 @@ def calibration_entry(target: str, axis: str, size: object, unit: str) -> Calibr
f"a statement in pixels has no size (found {size!r}): leave the size empty"
)
return CalibrationEntry(target, axis, None, PIXELS)
name = _unit(unit)
return CalibrationEntry(target, axis, _size(size), name)
name, size = _unit(unit), _size(size)
to_micrometers(size, name) # refuses one beyond micrometres: 1e308 m is inf (issue #93)
return CalibrationEntry(target, axis, size, name)


def _target(text: str) -> str:
Expand Down Expand Up @@ -181,6 +183,8 @@ def _size(value: object) -> float:
size = float(value.strip()) if isinstance(value, str) else float(value)
except (TypeError, ValueError):
raise ValueError(f"the size {value!r} is not a number") from None
except OverflowError: # an int beyond the float range
raise ValueError(f"the size {value!r} is not a positive finite number") from None
if not math.isfinite(size) or size <= 0:
raise ValueError(f"the size {value!r} is not a positive finite number")
return size
Expand Down
28 changes: 20 additions & 8 deletions src/morphobricks_preprocessing/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
``calibrate`` (:mod:`.cli_calibrate`) shows or states the pixel sizes in the output folder's
``calibration.csv`` (design v0.4 §3). ``convert`` (:mod:`.cli_convert`) saves the images whose
axes are wrong or not named as named-axis OME-TIFF copies in a new folder (design v0.4 §5).
All exit with status 1 when an error blocks processing.
All exit with status 1 when they report an error (an error blocks processing) and 0
otherwise; argparse exits with 2 on options it cannot read.
"""

from __future__ import annotations
Expand All @@ -33,7 +34,7 @@

from .cli_calibrate import add_calibrate_parser, calibrate_command, calibration_summary
from .cli_convert import add_convert_parser, convert_command
from .cli_design import add_design_parser, design_command, design_instructions
from .cli_design import add_design_parser, design_command, design_errors, design_instructions
from .cli_masks import add_masks_arguments, masks_command
from .cli_run import (
add_run_arguments,
Expand Down Expand Up @@ -240,8 +241,7 @@ def _check(args: argparse.Namespace) -> int:
if pre.issues:
print()
print(format_issues(pre.issues))
if pre.dataset.repeats:
_print_design(args, pre)
design_refused = bool(pre.dataset.repeats) and not _print_design(args, pre)
axis_errors = ()
if review is not None:
# The suggested roles are the header's own, so they never contradict it; 'run'
Expand Down Expand Up @@ -269,11 +269,17 @@ def _check(args: argparse.Namespace) -> int:
print()
refusal, path = written.refusal, written.report
print(format_issues([refusal]) if refusal else f"Report written to {path}" if path else "")
return 0 if pre.ok and not axis_errors and refusal is None else 1
blocked = not pre.ok or axis_errors or refusal is not None or design_refused
return 1 if blocked else 0 # every error printed exits with 1, as 'run' does


def _print_design(args: argparse.Namespace, pre: Preflight) -> None:
"""Print the experiment design in the output folder; ``check`` never replaces it."""
def _print_design(args: argparse.Namespace, pre: Preflight) -> bool:
"""Print the experiment design in the output folder; False when it has an error.

``check`` never replaces a design. One it cannot read, or one ``run`` would stop on
(``repeat_group_conflict``, ``invalid_experiment_design``), is printed with its error,
and ``check`` then exits with status 1.
"""
output = Path(args.output) if args.output else default_output_folder(pre.discovery.root)
try:
design, _ = load_design(output, pre.dataset.repeats)
Expand All @@ -284,14 +290,20 @@ def _print_design(args: argparse.Namespace, pre: Preflight) -> None:
if design is None:
print(f"Experiment design: {refusal.message}")
print(f" Suggested action: {refusal.action}")
elif (output / DESIGN_FILENAME).exists():
return not refusal.is_error
if (output / DESIGN_FILENAME).exists():
print(f"Experiment design ({output / DESIGN_FILENAME}):")
print(design_summary(design))
problems = design_errors(design) # what 'run' would stop on, as errors
if problems:
print(format_issues(problems))
return False
else:
print(
"Experiment design: not confirmed yet. 'check' writes experiment_design.csv, "
"prefilled from the folders, to the output folder for you to confirm."
)
return True


def _log_refusal(args: argparse.Namespace) -> str | None:
Expand Down
17 changes: 14 additions & 3 deletions src/morphobricks_preprocessing/cli_design.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
import argparse
from pathlib import Path

from .design import DESIGN_FILENAME, DesignFormatError, design_issues
from .design import DESIGN_FILENAME, DesignFormatError, ExperimentDesign, design_issues
from .design_edit import DesignEdit, DesignEditError, parse_repeat_group
from .report import design_table, format_issues
from .run import OutputLockedError
from .run.design_file import edit_design
from .run.lock import lock_issue
from .validation import ValidationIssue, has_errors


def add_design_parser(commands: argparse._SubParsersAction) -> argparse.ArgumentParser:
Expand Down Expand Up @@ -104,11 +105,21 @@ def design_command(args: argparse.Namespace) -> int:
path = Path(args.output).resolve() / DESIGN_FILENAME
print(f"Experiment design ({path}{', updated' if written else ''}):")
print(design_table(design))
problems = [issue for issue in design_issues(design) if issue.code != "design_not_confirmed"]
problems = design_errors(design)
if problems:
print()
print(format_issues(problems))
return 0
return 1 if has_errors(problems) else 0 # an error printed is an exit status of 1


def design_errors(design: ExperimentDesign) -> list[ValidationIssue]:
"""Return what stops a run on this design, other than groups not confirmed yet.

``invalid_experiment_design`` and ``repeat_group_conflict`` (:func:`.design.design_issues`),
which ``check`` and ``design`` report as errors as ``run`` does; ``design_not_confirmed``
is left out, since confirming is what these commands ask for.
"""
return [issue for issue in design_issues(design) if issue.code != "design_not_confirmed"]


def design_instructions(path: Path, output: Path) -> list[str]:
Expand Down
Loading
Loading