patchworks 2.3.0__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {patchworks-2.3.0 → patchworks-2.5.0}/PKG-INFO +1 -1
- {patchworks-2.3.0 → patchworks-2.5.0}/cliff.toml +3 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/dog.md +16 -11
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/performance.md +3 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/snakemake.md +42 -1
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_merge.py +31 -16
- patchworks-2.5.0/src/patchworks/_notify.py +157 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_occupancy.py +10 -3
- patchworks-2.5.0/src/patchworks/_progress.py +184 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/ome_zarr.py +34 -29
- patchworks-2.5.0/tests/test_notify.py +75 -0
- patchworks-2.5.0/tests/test_progress.py +60 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_run_multi.py +37 -0
- patchworks-2.5.0/workflow/Snakefile +64 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/common.yaml +9 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config.yaml +5 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/profile/slurm/config.yaml +10 -3
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/rules/common.smk +35 -3
- patchworks-2.5.0/workflow/rules/convert.smk +28 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/rules/merge.smk +3 -1
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/rules/segment.smk +5 -1
- patchworks-2.5.0/workflow/scripts/build_occupancy.py +33 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/convert.py +4 -1
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/merge.py +3 -2
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/run_multi.py +13 -26
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/segment_tile.py +18 -3
- patchworks-2.3.0/workflow/Snakefile +0 -29
- patchworks-2.3.0/workflow/rules/convert.smk +0 -10
- {patchworks-2.3.0 → patchworks-2.5.0}/.github/workflows/docs.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/.github/workflows/lint.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/.github/workflows/release.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/.gitignore +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/LICENSE +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/README.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/chunks.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/cluster.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/io.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/postprocess.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/relabel.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/tile_process.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/assets/logo.png +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/custom.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/custom_method.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/dog.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/stardist.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/getting_started.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/label_relations.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/measurements.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/merging.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/tiling.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/docs/index.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/mkdocs.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/pyproject.toml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/__init__.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_core.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_io.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_relations.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/dog.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_allocation.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_core.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_distributed.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_dog.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_gpu.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_napari.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_occupancy.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_postprocess.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_relations.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/README.md +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config_cilia.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/multi.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/pixi.toml +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/_pw.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/prepare_tiles.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchworks
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Tiled processing of arbitrarily large images with globally consistent labels
|
|
5
5
|
Project-URL: Homepage, https://github.com/imcf/patchworks
|
|
6
6
|
Project-URL: Issues, https://github.com/imcf/patchworks/issues
|
|
@@ -32,7 +32,10 @@ commit_preprocessors = [
|
|
|
32
32
|
]
|
|
33
33
|
commit_parsers = [
|
|
34
34
|
# Merge commits are noise in a changelog — the merged commits are already listed.
|
|
35
|
+
# Anchored to the message start so a commit that merely mentions "merge dev
|
|
36
|
+
# ... main" in its own text isn't swept up too.
|
|
35
37
|
{ message = "^Merge ", skip = true },
|
|
38
|
+
{ message = "(?i)^chore:\\s*merge dev.*main", skip = true },
|
|
36
39
|
{ message = "^feat", group = "✨ Features" },
|
|
37
40
|
{ message = "^fix", group = "🐛 Bug Fixes" },
|
|
38
41
|
{ message = "^perf", group = "⚡ Performance" },
|
|
@@ -147,11 +147,13 @@ example above — and the segment job deconvolves each tile with
|
|
|
147
147
|
`pycudadecon` before running the DoG detector:
|
|
148
148
|
|
|
149
149
|
```yaml
|
|
150
|
-
# config/config_cilia.yaml (excerpt)
|
|
150
|
+
# config/config_cilia.yaml (excerpt) — only what differs from common.yaml,
|
|
151
|
+
# which supplies the input, work_dir, tile_shape and skip_empty
|
|
151
152
|
channel: 2
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
# Per-axis halo [z, y, x], covering the PSF support (decon) + the DoG's
|
|
154
|
+
# high_sigma. A scalar 30 would expand a [16, 1024, 1024] tile to 5.3x the
|
|
155
|
+
# voxels it keeps, nearly all of it wasted z.
|
|
156
|
+
overlap: [8, 30, 30]
|
|
155
157
|
|
|
156
158
|
method: "custom"
|
|
157
159
|
label_name: "cilia_labels"
|
|
@@ -185,11 +187,11 @@ in `dxdata`/`dxpsf` from X/Y and `dzdata`/`dzpsf` from Z. Set any of them in
|
|
|
185
187
|
of the calibration in its config. If the store is uncalibrated the
|
|
186
188
|
workflow says so and passes nothing.
|
|
187
189
|
|
|
188
|
-
Run it exactly like a Cellpose config
|
|
190
|
+
Run it exactly like a Cellpose config — the shared settings come from
|
|
191
|
+
`config/common.yaml`, merged in ahead of this one:
|
|
189
192
|
|
|
190
193
|
```bash
|
|
191
|
-
python -m snakemake --workflow-profile profile/slurm
|
|
192
|
-
--configfile config/config_cilia.yaml
|
|
194
|
+
python -m snakemake --workflow-profile profile/slurm --configfile config/common.yaml config/config_cilia.yaml
|
|
193
195
|
```
|
|
194
196
|
|
|
195
197
|
Checklist specific to this config:
|
|
@@ -206,10 +208,13 @@ Checklist specific to this config:
|
|
|
206
208
|
a thin intensity/threshold halo isn't enough once deconvolution is in the
|
|
207
209
|
loop.
|
|
208
210
|
- **`skip_empty`:** the `prepare` rule (`workflow/scripts/prepare_tiles.py`)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
builds a max-pooled occupancy map and reduces it over each tile's **full**
|
|
212
|
+
footprint (`build_occupancy_map` + `tile_occupancy`) before submitting any
|
|
213
|
+
`segment` jobs, regardless of `method`. Cilia are small and often sit near
|
|
214
|
+
a tile's edge, which is precisely where the older centred-window preview
|
|
215
|
+
could miss them — this decides every tile exactly. No extra config needed
|
|
216
|
+
beyond `skip_empty: true` (the default), and the map is built once and
|
|
217
|
+
shared by every config against that store.
|
|
213
218
|
- Run alongside `config_cyto.yaml`/`config_nuclei.yaml` via `config/multi.yaml`
|
|
214
219
|
to also get the cilia→cell/nucleus relation — see *Relating cilia to their
|
|
215
220
|
cell*, below.
|
|
@@ -74,6 +74,9 @@ extra read-back of the staged data.
|
|
|
74
74
|
|
|
75
75
|
- `tile_shape="auto"` sizes tiles to free RAM (or VRAM with `use_gpu=True`).
|
|
76
76
|
- `skip_empty=True` with `estimate_empty_tiles()` skips background tiles.
|
|
77
|
+
That preview samples a centred window, so use `build_occupancy_map()` +
|
|
78
|
+
`tile_occupancy()` when the result becomes an authoritative skip list —
|
|
79
|
+
they are exact, and it is what the Snakemake workflow uses.
|
|
77
80
|
- A Dask **distributed** cluster (`make_local_cluster`) parallelises across
|
|
78
81
|
workers/GPUs; patchworks then defers concurrency to the cluster.
|
|
79
82
|
|
|
@@ -442,6 +442,46 @@ cyto_labels` and `cilia_labels -> nuclei_labels`) so you can use whichever
|
|
|
442
442
|
fits a given dataset. See `config/config_cilia.yaml`. Its deconvolution step
|
|
443
443
|
needs `pip install "patchworks[dog]"` in the segment jobs' environment.
|
|
444
444
|
|
|
445
|
+
## Email notifications
|
|
446
|
+
|
|
447
|
+
Set an address and the workflow mails you when the long steps finish or fail:
|
|
448
|
+
|
|
449
|
+
```yaml
|
|
450
|
+
# config/common.yaml (or config/config.yaml for a single-config run)
|
|
451
|
+
notify_email: "you@unibas.ch"
|
|
452
|
+
notify_events: ["finish", "error"] # any of: start, finish, error
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Leave `notify_email` empty (the default) and nothing is sent.
|
|
456
|
+
|
|
457
|
+
Per-job mail is **SLURM's own** `--mail-type`, not a message sent from inside
|
|
458
|
+
the job. That matters: the controller sends it, so it still arrives when a job
|
|
459
|
+
is OOM-killed or cancelled by the scheduler — exactly the failures worth
|
|
460
|
+
hearing about, and exactly the ones a notification sent from within the job
|
|
461
|
+
would miss.
|
|
462
|
+
|
|
463
|
+
It is applied to the long single-job steps only — `convert`, `occupancy` and
|
|
464
|
+
`merge`. `segment` is deliberately excluded: there is one job per tile batch,
|
|
465
|
+
so a thousand-tile run would mean hundreds of messages.
|
|
466
|
+
|
|
467
|
+
On top of that, the workflow itself sends:
|
|
468
|
+
|
|
469
|
+
| When | Mail |
|
|
470
|
+
| --- | --- |
|
|
471
|
+
| The run fails | subject `[patchworks] FAILED: <label_name>`, with the last 40 lines of the failing step's log — usually the traceback itself |
|
|
472
|
+
| The run succeeds | subject `[patchworks] done: <label_name>`, with the output label path |
|
|
473
|
+
|
|
474
|
+
These cover what SLURM cannot: a local run with no scheduler at all, and
|
|
475
|
+
failures where the useful content is the Python traceback rather than an exit
|
|
476
|
+
code.
|
|
477
|
+
|
|
478
|
+
!!! note "Delivery is best-effort, by design"
|
|
479
|
+
A notification can never fail a run. If no local `sendmail` exists and no
|
|
480
|
+
SMTP server answers on localhost, the failure is logged as a warning and
|
|
481
|
+
the pipeline carries on — a six-hour segmentation that worked must not be
|
|
482
|
+
reported as failed because a mail host was down. If you get the warning
|
|
483
|
+
but no mail, ask your cluster admins which relay host to use.
|
|
484
|
+
|
|
445
485
|
## Measurements
|
|
446
486
|
|
|
447
487
|
See [Measurements](measurements.md) for computing area/centroid/intensity
|
|
@@ -520,7 +560,8 @@ prologue. The simplest path is a single shared env that the compute nodes see.
|
|
|
520
560
|
| `cellpose is not installed` in a job | the job's env lacks `patchworks[cellpose]` |
|
|
521
561
|
| Reading the input fails | install the matching reader (`patchworks[imaris]`/`[bioio]` + a `bioio-*`) |
|
|
522
562
|
| Out of GPU memory | smaller `tile_shape`, or `do_3D: false` |
|
|
523
|
-
| A job fails with an empty SLURM log | read `logs/segment/<
|
|
563
|
+
| A job fails with an empty SLURM log | read the step's own log — `logs/convert.log`, `logs/prepare.log`, `logs/segment/<batch>.log`, `logs/merge.log` — the real traceback is there |
|
|
564
|
+
| A long step looks hung | every step logs progress (`… 4,200/8,064 (52%) after 31m, ~28m left`) roughly once a minute; `tail -f` the step's log above |
|
|
524
565
|
| Very slow | confirm GPU is used (`nvidia-smi`); try 2-D or a lower `level` |
|
|
525
566
|
|
|
526
567
|
## How it works (for the curious)
|
|
@@ -23,7 +23,6 @@ import logging
|
|
|
23
23
|
import os
|
|
24
24
|
import tempfile
|
|
25
25
|
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
-
from contextlib import nullcontext as _nullcontext
|
|
27
26
|
from itertools import product as _iproduct
|
|
28
27
|
from multiprocessing import Pool as _Pool
|
|
29
28
|
from pathlib import Path
|
|
@@ -35,11 +34,7 @@ import zarr
|
|
|
35
34
|
|
|
36
35
|
from ._chunks import cpu_allocation
|
|
37
36
|
from ._io import zarr_compressor_kwargs
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
from tqdm.auto import tqdm as _tqdm
|
|
41
|
-
except ImportError:
|
|
42
|
-
_tqdm = None
|
|
37
|
+
from ._progress import track
|
|
43
38
|
|
|
44
39
|
logger = logging.getLogger(__name__)
|
|
45
40
|
|
|
@@ -165,6 +160,7 @@ def _scan_touching_pairs(
|
|
|
165
160
|
label_offsets: "np.ndarray | None" = None,
|
|
166
161
|
n_workers: int = 1,
|
|
167
162
|
has_labels: "np.ndarray | None" = None,
|
|
163
|
+
progress: bool = False,
|
|
168
164
|
) -> np.ndarray:
|
|
169
165
|
"""Scan chunk-boundary slabs; return (N, 2) int64 array of touching pairs.
|
|
170
166
|
|
|
@@ -259,12 +255,26 @@ def _scan_touching_pairs(
|
|
|
259
255
|
|
|
260
256
|
nw = max(1, min(n_workers, len(tasks)))
|
|
261
257
|
if nw <= 1:
|
|
262
|
-
results =
|
|
258
|
+
results = list(
|
|
259
|
+
track(
|
|
260
|
+
(_one(t) for t in tasks),
|
|
261
|
+
"scan boundaries",
|
|
262
|
+
len(tasks),
|
|
263
|
+
enabled=progress,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
263
266
|
else:
|
|
264
267
|
# Reads and decompression release the GIL, so threads scale here and
|
|
265
268
|
# nothing has to be pickled across processes.
|
|
266
269
|
with ThreadPoolExecutor(max_workers=nw) as pool:
|
|
267
|
-
results = list(
|
|
270
|
+
results = list(
|
|
271
|
+
track(
|
|
272
|
+
pool.map(_one, tasks),
|
|
273
|
+
"scan boundaries",
|
|
274
|
+
len(tasks),
|
|
275
|
+
enabled=progress,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
268
278
|
|
|
269
279
|
all_pairs = [r for r in results if r is not None]
|
|
270
280
|
if not all_pairs:
|
|
@@ -609,6 +619,7 @@ def zarr_native_merge(
|
|
|
609
619
|
label_offsets=offsets,
|
|
610
620
|
n_workers=n_workers,
|
|
611
621
|
has_labels=has_labels,
|
|
622
|
+
progress=show_progress,
|
|
612
623
|
)
|
|
613
624
|
logger.info(
|
|
614
625
|
"zarr_native_merge: %d touching pairs → building LUT", len(pairs)
|
|
@@ -743,9 +754,9 @@ def zarr_native_merge(
|
|
|
743
754
|
_init_worker(
|
|
744
755
|
lut_path, staged_path, staged_component, out_path, out_component
|
|
745
756
|
)
|
|
746
|
-
it: Any =
|
|
747
|
-
|
|
748
|
-
|
|
757
|
+
it: Any = track(
|
|
758
|
+
tasks, "relabel chunks", n_chunks, enabled=show_progress
|
|
759
|
+
)
|
|
749
760
|
for task in it:
|
|
750
761
|
_relabel_chunk_worker(task)
|
|
751
762
|
else:
|
|
@@ -760,9 +771,12 @@ def zarr_native_merge(
|
|
|
760
771
|
out_component,
|
|
761
772
|
),
|
|
762
773
|
) as pool:
|
|
763
|
-
it =
|
|
764
|
-
|
|
765
|
-
|
|
774
|
+
it = track(
|
|
775
|
+
pool.imap_unordered(_relabel_chunk_worker, tasks),
|
|
776
|
+
"relabel chunks",
|
|
777
|
+
n_chunks,
|
|
778
|
+
enabled=show_progress,
|
|
779
|
+
)
|
|
766
780
|
for _ in it:
|
|
767
781
|
pass
|
|
768
782
|
finally:
|
|
@@ -926,9 +940,10 @@ def merge_tile_labels(
|
|
|
926
940
|
stage_path = os.path.join(_base, "_pws_stage.zarr")
|
|
927
941
|
|
|
928
942
|
import dask
|
|
929
|
-
from dask.diagnostics import ProgressBar
|
|
930
943
|
|
|
931
|
-
|
|
944
|
+
from ._progress import dask_progress
|
|
945
|
+
|
|
946
|
+
ctx = dask_progress("stage tiles", progress)
|
|
932
947
|
logger.info("Staging per-tile labels to %s …", stage_path)
|
|
933
948
|
with ctx:
|
|
934
949
|
dask.compute(
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Best-effort email notification.
|
|
2
|
+
|
|
3
|
+
Used for the events SLURM cannot report on its own: the end of a whole
|
|
4
|
+
multi-config run, and failures in a local (non-SLURM) run. Per-job
|
|
5
|
+
start/finish mail on a cluster is left to SLURM's own ``--mail-type``, which
|
|
6
|
+
is delivered by the controller and does not depend on a compute node being
|
|
7
|
+
able to reach an MTA.
|
|
8
|
+
|
|
9
|
+
Every function here is best-effort by design: a notification that cannot be
|
|
10
|
+
delivered must never fail a pipeline that otherwise succeeded, and must never
|
|
11
|
+
turn a real error into a confusing one about email.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import shutil
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
from email.message import EmailMessage
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Union
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Lines of a failing step's log to quote in an error mail. Enough to carry a
|
|
27
|
+
# traceback, short enough that the mail stays readable on a phone.
|
|
28
|
+
LOG_TAIL_LINES = 40
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_tail(path: Union[str, Path], lines: int = LOG_TAIL_LINES) -> str:
|
|
32
|
+
"""Return the last *lines* of a log file, or a note if unreadable."""
|
|
33
|
+
try:
|
|
34
|
+
content = Path(path).read_text(errors="replace").splitlines()
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
return f"(could not read {path}: {exc})"
|
|
37
|
+
if not content:
|
|
38
|
+
return f"({path} is empty)"
|
|
39
|
+
return "\n".join(content[-lines:])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def send(
|
|
43
|
+
to: Union[str, None],
|
|
44
|
+
subject: str,
|
|
45
|
+
body: str,
|
|
46
|
+
*,
|
|
47
|
+
sender: Union[str, None] = None,
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""Send *body* to *to*, returning whether it went out.
|
|
50
|
+
|
|
51
|
+
Tries a local ``sendmail`` first (what a cluster node normally has), then
|
|
52
|
+
an SMTP server on localhost. Never raises.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
to : str or None
|
|
57
|
+
Recipient. ``None`` or empty disables the notification entirely, which
|
|
58
|
+
is the default state -- no address configured, no mail, no error.
|
|
59
|
+
subject : str
|
|
60
|
+
Subject line.
|
|
61
|
+
body : str
|
|
62
|
+
Plain-text body.
|
|
63
|
+
sender : str, optional
|
|
64
|
+
From address. Defaults to ``patchworks@<hostname>``.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
bool
|
|
69
|
+
True when the message was handed to a transport.
|
|
70
|
+
"""
|
|
71
|
+
if not to:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
msg = EmailMessage()
|
|
75
|
+
msg["To"] = to
|
|
76
|
+
msg["From"] = sender or f"patchworks@{socket.getfqdn()}"
|
|
77
|
+
msg["Subject"] = subject
|
|
78
|
+
msg.set_content(body)
|
|
79
|
+
|
|
80
|
+
sendmail = shutil.which("sendmail") or shutil.which(
|
|
81
|
+
"sendmail", path="/usr/sbin:/usr/lib"
|
|
82
|
+
)
|
|
83
|
+
if sendmail:
|
|
84
|
+
try:
|
|
85
|
+
subprocess.run(
|
|
86
|
+
[sendmail, "-t", "-oi"],
|
|
87
|
+
input=msg.as_bytes(),
|
|
88
|
+
check=True,
|
|
89
|
+
capture_output=True,
|
|
90
|
+
timeout=30,
|
|
91
|
+
)
|
|
92
|
+
return True
|
|
93
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
94
|
+
logger.warning(
|
|
95
|
+
"sendmail failed (%s); trying SMTP on localhost", exc
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import smtplib
|
|
100
|
+
|
|
101
|
+
with smtplib.SMTP("localhost", timeout=30) as smtp:
|
|
102
|
+
smtp.send_message(msg)
|
|
103
|
+
return True
|
|
104
|
+
except (OSError, Exception) as exc: # noqa: BLE001 - never fail the run
|
|
105
|
+
logger.warning(
|
|
106
|
+
"could not send notification to %s (%s). The run itself is "
|
|
107
|
+
"unaffected; set notify_email to '' to silence this.",
|
|
108
|
+
to,
|
|
109
|
+
exc,
|
|
110
|
+
)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def slurm_mail_extra(
|
|
115
|
+
email: Union[str, None], events: Union[list, tuple, None] = None
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Build the ``slurm_extra`` fragment for per-job mail.
|
|
118
|
+
|
|
119
|
+
SLURM's own ``--mail-type`` is used rather than sending from inside the
|
|
120
|
+
job: the controller delivers it, so it still arrives when the job is
|
|
121
|
+
killed by the OOM reaper or the scheduler -- exactly the cases worth
|
|
122
|
+
hearing about, and exactly the ones an in-job notification misses.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
email : str or None
|
|
127
|
+
Recipient; empty/None yields an empty string (no mail configured).
|
|
128
|
+
events : sequence of str, optional
|
|
129
|
+
Any of ``"start"``, ``"finish"``, ``"error"``. Defaults to finish and
|
|
130
|
+
error -- a BEGIN mail per job is rarely worth the inbox.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
str
|
|
135
|
+
Something like ``--mail-type=END,FAIL --mail-user=me@example.org``,
|
|
136
|
+
or ``""`` when no address is configured.
|
|
137
|
+
|
|
138
|
+
Examples
|
|
139
|
+
--------
|
|
140
|
+
>>> slurm_mail_extra("me@example.org", ["error"])
|
|
141
|
+
'--mail-type=FAIL --mail-user=me@example.org'
|
|
142
|
+
>>> slurm_mail_extra(None)
|
|
143
|
+
''
|
|
144
|
+
"""
|
|
145
|
+
if not email:
|
|
146
|
+
return ""
|
|
147
|
+
mapping = {"start": "BEGIN", "finish": "END", "error": "FAIL"}
|
|
148
|
+
chosen = list(events) if events else ["finish", "error"]
|
|
149
|
+
unknown = sorted(set(chosen) - set(mapping))
|
|
150
|
+
if unknown:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"unknown notify_events {unknown}; use any of "
|
|
153
|
+
f"{sorted(mapping)} (they map to SLURM's BEGIN/END/FAIL)"
|
|
154
|
+
)
|
|
155
|
+
# Keep SLURM's own order, not the config's, so the string is stable.
|
|
156
|
+
types = [mapping[k] for k in ("start", "finish", "error") if k in chosen]
|
|
157
|
+
return f"--mail-type={','.join(types)} --mail-user={email}"
|
|
@@ -36,6 +36,7 @@ import numpy as np
|
|
|
36
36
|
import zarr
|
|
37
37
|
|
|
38
38
|
from ._chunks import cpu_allocation
|
|
39
|
+
from ._progress import track
|
|
39
40
|
|
|
40
41
|
logger = logging.getLogger(__name__)
|
|
41
42
|
|
|
@@ -271,13 +272,19 @@ def build_occupancy_map(
|
|
|
271
272
|
try:
|
|
272
273
|
n_workers = max(1, min(cpu_allocation(), len(regions)))
|
|
273
274
|
if n_workers <= 1:
|
|
274
|
-
for
|
|
275
|
-
_one(starts)
|
|
275
|
+
for _ in track(
|
|
276
|
+
(_one(starts) for starts in regions),
|
|
277
|
+
"occupancy map",
|
|
278
|
+
len(regions),
|
|
279
|
+
):
|
|
280
|
+
pass
|
|
276
281
|
else:
|
|
277
282
|
# Reads and decompression release the GIL, so threads are enough
|
|
278
283
|
# and there is no worker payload to pickle.
|
|
279
284
|
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
|
280
|
-
for _ in
|
|
285
|
+
for _ in track(
|
|
286
|
+
pool.map(_one, regions), "occupancy map", len(regions)
|
|
287
|
+
):
|
|
281
288
|
pass
|
|
282
289
|
dst.attrs["block"] = list(block)
|
|
283
290
|
dst.attrs["level"] = int(level)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Progress reporting that survives being written to a log file.
|
|
2
|
+
|
|
3
|
+
Every long step in the workflow runs unattended in a batch job, where the
|
|
4
|
+
output is read hours later out of a file. A redrawing bar (dask's
|
|
5
|
+
``ProgressBar``, ``tqdm``) collapses into one enormous unreadable line there,
|
|
6
|
+
so the default here is periodic log records instead -- and a bar only when
|
|
7
|
+
someone is actually watching a terminal.
|
|
8
|
+
|
|
9
|
+
The interval matters more than the precision: the job runs for hours, and the
|
|
10
|
+
question being answered is "is this working or hung?", not "exactly how far".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from contextlib import nullcontext
|
|
19
|
+
from typing import Iterable, Iterator, TypeVar
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
# Often enough to tell progress from a hang, rare enough that a six-hour job
|
|
26
|
+
# leaves a log you can still read.
|
|
27
|
+
PROGRESS_INTERVAL_S = 60.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_interactive() -> bool:
|
|
31
|
+
"""True when someone is plausibly watching a terminal."""
|
|
32
|
+
return bool(getattr(sys.stderr, "isatty", lambda: False)())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_eta(done: int, total: int, elapsed: float) -> str:
|
|
36
|
+
"""Rough remaining time from a linear extrapolation.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
done, total : int
|
|
41
|
+
Units finished and expected.
|
|
42
|
+
elapsed : float
|
|
43
|
+
Seconds spent so far.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
str
|
|
48
|
+
A short human-readable duration, or ``"?"`` when it cannot be
|
|
49
|
+
estimated yet.
|
|
50
|
+
"""
|
|
51
|
+
if done <= 0 or done >= total:
|
|
52
|
+
return "?"
|
|
53
|
+
left = elapsed / done * (total - done)
|
|
54
|
+
if left < 90:
|
|
55
|
+
return f"{left:.0f}s"
|
|
56
|
+
if left < 5400:
|
|
57
|
+
return f"{left / 60:.0f}m"
|
|
58
|
+
return f"{left / 3600:.1f}h"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def log_progress(label: str, done: int, total: int, started: float) -> None:
|
|
62
|
+
"""Emit one progress line.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
label : str
|
|
67
|
+
What is being worked on, e.g. ``"image.zarr/0"``.
|
|
68
|
+
done, total : int
|
|
69
|
+
Units finished and expected.
|
|
70
|
+
started : float
|
|
71
|
+
``time.monotonic()`` when the work began.
|
|
72
|
+
"""
|
|
73
|
+
elapsed = time.monotonic() - started
|
|
74
|
+
logger.info(
|
|
75
|
+
"%s: %s/%s (%.0f%%) after %.0fm, ~%s left",
|
|
76
|
+
label,
|
|
77
|
+
f"{done:,}",
|
|
78
|
+
f"{total:,}",
|
|
79
|
+
100.0 * done / max(1, total),
|
|
80
|
+
elapsed / 60,
|
|
81
|
+
format_eta(done, total, elapsed),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def track(
|
|
86
|
+
iterable: Iterable[T],
|
|
87
|
+
label: str,
|
|
88
|
+
total: int,
|
|
89
|
+
*,
|
|
90
|
+
enabled: bool = True,
|
|
91
|
+
) -> Iterator[T]:
|
|
92
|
+
"""Yield from *iterable*, reporting progress as it goes.
|
|
93
|
+
|
|
94
|
+
Uses ``tqdm`` when attached to a terminal and periodic log lines
|
|
95
|
+
otherwise, so the same call is right in a notebook and in a SLURM job.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
iterable : iterable
|
|
100
|
+
The work to iterate. Consumed lazily, so this is safe over
|
|
101
|
+
``imap_unordered``.
|
|
102
|
+
label : str
|
|
103
|
+
Description of the work.
|
|
104
|
+
total : int
|
|
105
|
+
Expected number of items, used for the percentage and the ETA.
|
|
106
|
+
enabled : bool, optional
|
|
107
|
+
Set ``False`` to pass items straight through. Default ``True``.
|
|
108
|
+
|
|
109
|
+
Yields
|
|
110
|
+
------
|
|
111
|
+
object
|
|
112
|
+
The items of *iterable*, unchanged.
|
|
113
|
+
"""
|
|
114
|
+
if not enabled:
|
|
115
|
+
yield from iterable
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if is_interactive():
|
|
119
|
+
try:
|
|
120
|
+
from tqdm.auto import tqdm
|
|
121
|
+
|
|
122
|
+
yield from tqdm(iterable, total=total, desc=label)
|
|
123
|
+
return
|
|
124
|
+
except ImportError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
started = time.monotonic()
|
|
128
|
+
last = started
|
|
129
|
+
done = 0
|
|
130
|
+
logger.info("%s: starting (%s items)", label, f"{total:,}")
|
|
131
|
+
for item in iterable:
|
|
132
|
+
yield item
|
|
133
|
+
done += 1
|
|
134
|
+
now = time.monotonic()
|
|
135
|
+
if now - last >= PROGRESS_INTERVAL_S:
|
|
136
|
+
last = now
|
|
137
|
+
log_progress(label, done, total, started)
|
|
138
|
+
log_progress(label, done, total, started)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def dask_progress(label: str, enabled: bool = True):
|
|
142
|
+
"""Progress context manager for a dask computation.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
contextmanager
|
|
147
|
+
``ProgressBar`` on a terminal, a periodically-logging callback
|
|
148
|
+
otherwise, or a no-op when *enabled* is false.
|
|
149
|
+
"""
|
|
150
|
+
if not enabled:
|
|
151
|
+
return nullcontext()
|
|
152
|
+
|
|
153
|
+
logger.info("writing %s …", label)
|
|
154
|
+
if is_interactive():
|
|
155
|
+
from dask.diagnostics import ProgressBar
|
|
156
|
+
|
|
157
|
+
return ProgressBar()
|
|
158
|
+
|
|
159
|
+
from dask.callbacks import Callback
|
|
160
|
+
|
|
161
|
+
class _LogProgress(Callback):
|
|
162
|
+
"""Count finished dask tasks and log every PROGRESS_INTERVAL_S."""
|
|
163
|
+
|
|
164
|
+
def _start_state(self, dsk, state):
|
|
165
|
+
self._total = sum(
|
|
166
|
+
len(state[k])
|
|
167
|
+
for k in ("ready", "waiting", "running", "finished")
|
|
168
|
+
)
|
|
169
|
+
self._done = 0
|
|
170
|
+
self._t0 = time.monotonic()
|
|
171
|
+
self._last = self._t0
|
|
172
|
+
|
|
173
|
+
def _posttask(self, key, result, dsk, state, worker_id):
|
|
174
|
+
self._done += 1
|
|
175
|
+
now = time.monotonic()
|
|
176
|
+
if now - self._last >= PROGRESS_INTERVAL_S:
|
|
177
|
+
self._last = now
|
|
178
|
+
log_progress(label, self._done, self._total, self._t0)
|
|
179
|
+
|
|
180
|
+
def _finish(self, dsk, state, errored):
|
|
181
|
+
if not errored:
|
|
182
|
+
log_progress(label, self._total, self._total, self._t0)
|
|
183
|
+
|
|
184
|
+
return _LogProgress()
|