worldarray 0.3.1__tar.gz → 0.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.
Files changed (38) hide show
  1. {worldarray-0.3.1 → worldarray-0.5.0}/CHANGELOG.md +67 -2
  2. {worldarray-0.3.1 → worldarray-0.5.0}/PKG-INFO +15 -9
  3. {worldarray-0.3.1 → worldarray-0.5.0}/README.md +14 -8
  4. {worldarray-0.3.1 → worldarray-0.5.0}/docs/spec.md +156 -27
  5. {worldarray-0.3.1 → worldarray-0.5.0}/pyproject.toml +1 -1
  6. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/__init__.py +3 -2
  7. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/array.py +126 -18
  8. worldarray-0.5.0/src/worldarray/context.py +205 -0
  9. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/retry.py +44 -13
  10. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/store.py +3 -1
  11. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/write.py +12 -2
  12. {worldarray-0.3.1 → worldarray-0.5.0}/tests/test_access.py +94 -1
  13. worldarray-0.5.0/tests/test_lost_wakeup.py +76 -0
  14. {worldarray-0.3.1 → worldarray-0.5.0}/tests/test_read.py +6 -0
  15. {worldarray-0.3.1 → worldarray-0.5.0}/tests/test_resample.py +42 -0
  16. {worldarray-0.3.1 → worldarray-0.5.0}/tests/test_write.py +94 -0
  17. {worldarray-0.3.1 → worldarray-0.5.0}/uv.lock +1 -1
  18. worldarray-0.3.1/src/worldarray/context.py +0 -84
  19. {worldarray-0.3.1 → worldarray-0.5.0}/.github/workflows/ci.yml +0 -0
  20. {worldarray-0.3.1 → worldarray-0.5.0}/.github/workflows/docs.yml +0 -0
  21. {worldarray-0.3.1 → worldarray-0.5.0}/.github/workflows/publish.yml +0 -0
  22. {worldarray-0.3.1 → worldarray-0.5.0}/.gitignore +0 -0
  23. {worldarray-0.3.1 → worldarray-0.5.0}/LICENSE +0 -0
  24. {worldarray-0.3.1 → worldarray-0.5.0}/docs/changelog.md +0 -0
  25. {worldarray-0.3.1 → worldarray-0.5.0}/docs/index.md +0 -0
  26. {worldarray-0.3.1 → worldarray-0.5.0}/mkdocs.yml +0 -0
  27. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/geometry.py +0 -0
  28. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/grid.py +0 -0
  29. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/levels.py +0 -0
  30. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/placement.py +0 -0
  31. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/resample.py +0 -0
  32. {worldarray-0.3.1 → worldarray-0.5.0}/src/worldarray/units.py +0 -0
  33. {worldarray-0.3.1 → worldarray-0.5.0}/tests/conftest.py +0 -0
  34. {worldarray-0.3.1 → worldarray-0.5.0}/tests/fixtures/metadata.json +0 -0
  35. {worldarray-0.3.1 → worldarray-0.5.0}/tests/fixtures/write.json +0 -0
  36. {worldarray-0.3.1 → worldarray-0.5.0}/tests/test_geometry.py +0 -0
  37. {worldarray-0.3.1 → worldarray-0.5.0}/tests/test_grid.py +0 -0
  38. {worldarray-0.3.1 → worldarray-0.5.0}/tests/test_metadata.py +0 -0
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-10-23)
4
+
5
+ Two bugs that hung or crashed cellmap-analyze 0.6 on /nrs (salamander's and c-elegans'
6
+ runs, 2026-10-22), and tensorstore's threads sized to the job. No placement change and no
7
+ unit table change.
8
+
9
+ - A read whose first attempt timed out raised `IndexError` instead of retrying: the
10
+ warning took the first line of the error's message, and tensorstore's `TimeoutError()`
11
+ has none. The warning now names the error's type when the message is empty, and the
12
+ error after the last attempt names the operation, the path and the attempts.
13
+ - Every context the package makes or opens a store on is watched: once a second a daemon
14
+ thread gives its decoding and file I/O pools one no-op task each (an in-memory copy and
15
+ a local read of a missing key; no store is touched). tensorstore up to at least 0.1.85
16
+ can strand a queued task: a pool worker idle for 20 ms exits as the task arrives, and
17
+ the task never runs (fixed upstream in google/tensorstore ed51718085, in no release
18
+ yet). With `data_copy` and `file_io` capped at 1, as cellmap-analyze's workers were,
19
+ dozens of workers per run waited forever in `write_index` with no tensorstore thread
20
+ alive, and reads timed out into the `IndexError` above. One more task on the same pool
21
+ wakes it, which salamander's and c-elegans' sessions proved on live stuck workers, so a
22
+ stranded task now runs within about a second, at any concurrency, whoever waits on it:
23
+ worldarray, `vol.ts` directly or `await`. Reproduced on purpose on tensorstore 0.1.75,
24
+ 0.1.78 and 0.1.85 (opt-in `tests/test_lost_wakeup.py`): cellmap-analyze's block loop
25
+ gave 29 `IndexError`s and 55 writes stuck past 20 s on 0.4.0, none on 0.5.0, slowest
26
+ 0.98 s. Spec open question 14 says when this goes.
27
+ - `write_index`, `write` and `write_chunk` are timed out and retried as reads are
28
+ (Behaviour 6), by a new `retries=` argument or the handle's `retries`. A write used to
29
+ wait on its future with no timeout at all.
30
+ - Without `context`, the shared context's decoding and file I/O pools are limited to the
31
+ new `wa.thread_budget()`: `OMP_NUM_THREADS` when set (dask sets 1 in each worker), else
32
+ the job's slots (`LSB_DJOB_NUMPROC`, `SLURM_CPUS_PER_TASK`, `NSLOTS`), else the CPUs the
33
+ process may run on. A server that wants more threads than its job's slots asks for more
34
+ slots or passes its own context.
35
+ tensorstore alone sizes both to every CPU on the node, whatever `bsub -n` asked for, so
36
+ a dask worker's tensorstore now takes the same one thread as its numpy (pinned to one
37
+ CPU, tensorstore's default context started 43 to 45 pool threads, the capped one 4 to 5).
38
+ Against cellmap-analyze 0.5.0's own block I/O on NFS (128^3 uint64 label blocks, each
39
+ one chunk), its 0.6.1 on this release reads a block in 26 ms instead of 40 and writes it
40
+ in 45 ms instead of 62 from one process at one thread per pool, and about 1.15 times
41
+ faster from 8. Speed gains grow with blocks of several chunks and more threads.
42
+
43
+ ## 0.4.0 (2026-10-22)
44
+
45
+ cellmap-analyze's three asks, so it can call worldarray directly with no wrapper, and two
46
+ of cellmap-flow's. No placement change and no unit table change.
47
+
48
+ - `vol.read_onto(grid, begin=None, end=None, ...)` reads voxels `[begin, end)` of a
49
+ `Grid`'s spatial axes from the array, exactly that shape (Behaviour 20). It is the
50
+ `read` of the grid's index box at the grid's voxel size anchored at its corner, so at
51
+ the array's own voxel size nothing is resampled, whatever the sub-voxel offset, and
52
+ otherwise the array is resampled onto the grid. The grid's units are converted.
53
+ - A pickled handle, and `reopen`, use the path resolved against the working directory at
54
+ open, so a worker that changes directory before unpickling opens the same store; it
55
+ used to raise `FileNotFoundError` for a relative path.
56
+ - `write_index`, `write` and `write_chunk` take a scalar, which fills the box. It is
57
+ converted to the dtype only when its value survives (`0` into `uint8`; `300`, `-1`, `0.5`
58
+ and NaN raise), and the fill value over whole units leaves them absent.
59
+ - `vol.source_translation`: the stored level's translation in `units`, beside
60
+ `source_voxel_size`, under resample and relabel too (cellmap-flow's `stored_placement`).
61
+ - `create(exists="adopt")` also compares the keys of `attrs` with the existing array's
62
+ attributes and raises naming each that differs or is missing; keys not given are not
63
+ compared. An adopt given different `attrs` used to keep the old ones silently, as funlib's
64
+ `prepare_ds` kept an old offset (cellmap-flow's `_refuse_another_region`).
65
+
3
66
  ## 0.3.1 (2026-10-22)
4
67
 
5
68
  - `source_voxel_size` reports the stored level's voxel size under `on_mismatch="relabel"`,
@@ -10,8 +73,10 @@
10
73
  `Grid.from_meta(meta, voxel_size=t).box` already was; it was the stored data's extent, so
11
74
  `read(box)` could return fewer voxels than `shape` (cellmap-flow's 7 voxels of 8 nm at
12
75
  24 nm: box ended at 56 nm, now 72). No voxel moves.
13
- - The unit table knows kilometres (`kilometer`, `kilometre`, `km`, 1e3 m), so they
14
- convert, abbreviate to `km` and are written as OME's `kilometer` (chunkmirage's ask).
76
+ - **Unit table change.** The unit table knows kilometres (`kilometer`, `kilometre`, `km`,
77
+ 1e3 m), so they convert, abbreviate to `km` and are written as OME's `kilometer`
78
+ (chunkmirage's ask). This should have been a minor release (spec section 8): cellmap-flow
79
+ exports the table and its CI failed until it moved its pin to `>=0.3.1`.
15
80
  - README: the placement rule gains the corner formula, a worked example, why the centre
16
81
  is the model, and how the read rule follows from it.
17
82
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.3.1
3
+ Version: 0.5.0
4
4
  Summary: Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only.
5
5
  Project-URL: Homepage, https://janeliascicomp.github.io/worldarray/
6
6
  Project-URL: Documentation, https://janeliascicomp.github.io/worldarray/
@@ -109,6 +109,7 @@ six = wa.open(path, voxel_size=(6, 6, 6), on_mismatch="resample")
109
109
 
110
110
  # one volume read onto another volume's grid, for masks, contact sites, statistics
111
111
  onto = raw.read(box, voxel_size=labels.voxel_size, anchor=labels.corner)
112
+ onto = raw.read_onto(grid, begin, end) # voxels [begin, end) of a Grid, exactly that shape
112
113
 
113
114
  # index space, native units: what an engine or a viewer wants
114
115
  vol = wa.open(path, units=None)
@@ -144,9 +145,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
144
145
  `transform` applied before padding (or `post_transform` after resampling), and resampling by nearest neighbour or linear
145
146
  interpolation anchored where you say. Adjoining block reads agree with one read across
146
147
  both.
147
- - **Behaves on a cluster**: one shared tensorstore context per process or a capped one per
148
- worker, retries with backoff and size-aware timeouts on transient store errors, and a
149
- cancelled future on timeout.
148
+ - **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
149
+ from `OMP_NUM_THREADS` or the LSF job's slots instead of the node's cores, or a capped one
150
+ per worker; reads and writes retried with backoff and size-aware timeouts on transient
151
+ store errors, a cancelled future on timeout, and every context's thread pools nudged once
152
+ a second against a tensorstore bug that can leave a queued task with no thread, so a
153
+ stuck operation recovers or fails loudly instead of hanging the job.
150
154
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
151
155
  `snap` to a grid, and the voxel indices a world box selects.
152
156
  - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
@@ -159,8 +163,8 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
159
163
  - **Plans outputs and blocks** (0.3): a store-less `Grid` with the same geometry as an
160
164
  array, so an output is planned, cut into blocks and only then created; `blocks` tiling an
161
165
  array or a box into write boxes with context-grown read boxes, in C order, as plain
162
- picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule.
163
- Nothing is scheduled or run.
166
+ picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule; every
167
+ input read onto that grid by its voxel indices (0.4). Nothing is scheduled or run.
164
168
 
165
169
  ## The placement rule
166
170
 
@@ -205,6 +209,7 @@ wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False
205
209
  wa.detect(path, missing_ok=False) -> Node # format, array or group, raw attrs
206
210
  wa.open_tensorstore(path, level=None, context=None)
207
211
  wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
212
+ wa.thread_budget() # the shared context's thread limit: OMP_NUM_THREADS, the job's slots, or CPUs
208
213
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
209
214
  wa.Box ; wa.units.TABLE
210
215
 
@@ -227,12 +232,13 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
227
232
  ```
228
233
 
229
234
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
230
- `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`,
235
+ `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `source_translation`, `resampled`,
231
236
  `writable`, `write_shape`;
232
237
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
233
- `read_index(begin, end, padding=, mask=, select=, transform=)`, `index_box(box)`,
238
+ `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
234
239
  `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
235
- `write_index(begin, end, data, partial=)`, `write(box, data, partial=)`, `reopen(context=)`.
240
+ `write_index(begin, end, data, partial=, retries=)` and `write(box, data, partial=, retries=)` (`data` an array or a
241
+ scalar), `reopen(context=)`.
236
242
 
237
243
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
238
244
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -50,6 +50,7 @@ six = wa.open(path, voxel_size=(6, 6, 6), on_mismatch="resample")
50
50
 
51
51
  # one volume read onto another volume's grid, for masks, contact sites, statistics
52
52
  onto = raw.read(box, voxel_size=labels.voxel_size, anchor=labels.corner)
53
+ onto = raw.read_onto(grid, begin, end) # voxels [begin, end) of a Grid, exactly that shape
53
54
 
54
55
  # index space, native units: what an engine or a viewer wants
55
56
  vol = wa.open(path, units=None)
@@ -85,9 +86,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
85
86
  `transform` applied before padding (or `post_transform` after resampling), and resampling by nearest neighbour or linear
86
87
  interpolation anchored where you say. Adjoining block reads agree with one read across
87
88
  both.
88
- - **Behaves on a cluster**: one shared tensorstore context per process or a capped one per
89
- worker, retries with backoff and size-aware timeouts on transient store errors, and a
90
- cancelled future on timeout.
89
+ - **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
90
+ from `OMP_NUM_THREADS` or the LSF job's slots instead of the node's cores, or a capped one
91
+ per worker; reads and writes retried with backoff and size-aware timeouts on transient
92
+ store errors, a cancelled future on timeout, and every context's thread pools nudged once
93
+ a second against a tensorstore bug that can leave a queued task with no thread, so a
94
+ stuck operation recovers or fails loudly instead of hanging the job.
91
95
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
92
96
  `snap` to a grid, and the voxel indices a world box selects.
93
97
  - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
@@ -100,8 +104,8 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
100
104
  - **Plans outputs and blocks** (0.3): a store-less `Grid` with the same geometry as an
101
105
  array, so an output is planned, cut into blocks and only then created; `blocks` tiling an
102
106
  array or a box into write boxes with context-grown read boxes, in C order, as plain
103
- picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule.
104
- Nothing is scheduled or run.
107
+ picklable records a cluster job takes by id; the anisotropy-aware pyramid schedule; every
108
+ input read onto that grid by its voxel indices (0.4). Nothing is scheduled or run.
105
109
 
106
110
  ## The placement rule
107
111
 
@@ -146,6 +150,7 @@ wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False
146
150
  wa.detect(path, missing_ok=False) -> Node # format, array or group, raw attrs
147
151
  wa.open_tensorstore(path, level=None, context=None)
148
152
  wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
153
+ wa.thread_budget() # the shared context's thread limit: OMP_NUM_THREADS, the job's slots, or CPUs
149
154
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
150
155
  wa.Box ; wa.units.TABLE
151
156
 
@@ -168,12 +173,13 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
168
173
  ```
169
174
 
170
175
  `WorldArray`: `ts`, `meta`, `grid`, `shape`, `dtype`, `axes`, `voxel_size`, `translation`,
171
- `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `resampled`,
176
+ `corner`, `box`, `levels`, `level_name`, `level_path`, `source_voxel_size`, `source_translation`, `resampled`,
172
177
  `writable`, `write_shape`;
173
178
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
174
- `read_index(begin, end, padding=, mask=, select=, transform=)`, `index_box(box)`,
179
+ `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
175
180
  `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
176
- `write_index(begin, end, data, partial=)`, `write(box, data, partial=)`, `reopen(context=)`.
181
+ `write_index(begin, end, data, partial=, retries=)` and `write(box, data, partial=, retries=)` (`data` an array or a
182
+ scalar), `reopen(context=)`.
177
183
 
178
184
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
179
185
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -149,8 +149,9 @@ vol.meta # ArrayMeta in the store's own units
149
149
  vol.grid # ArrayMeta in the requested units, every axis
150
150
  vol.shape, vol.dtype, vol.axes, vol.spatial, vol.level_name, vol.levels, vol.remote
151
151
  # shape is the stored shape, or with a resample target the target voxels covering the data
152
- vol.source_voxel_size, vol.resampled # the stored level's voxel size, also under resample and relabel; whether reads
153
- # resample to a target
152
+ vol.source_voxel_size, vol.source_translation, vol.resampled
153
+ # the stored level's voxel size and translation (0.4), also under resample and relabel; whether
154
+ # reads resample to a target
154
155
  vol.list_chunks() # the chunk indices that exist in storage, by listing keys (zarr v2/v3, N5, unsharded precomputed)
155
156
  vol.path # what the caller passed (a group, say); vol.level_path is the array actually opened
156
157
  vol.voxel_size # tuple[float] in `units`, spatial axes, the array's order (zyx for a volume)
@@ -229,7 +230,8 @@ in integer Rois builds them from `corner`, `voxel_size` and `shape` in its own s
229
230
  where it also chooses the integer scale factor that makes the corner whole.
230
231
 
231
232
  `shared_context(cache_bytes)` is one `tensorstore.Context` per budget for the process, so
232
- the levels of a pyramid and repeat opens share one cache pool; the default budget is 0.
233
+ the levels of a pyramid and repeat opens share one cache pool; the default budget is 0. Its
234
+ decoding and file I/O pools are sized by `thread_budget()` (0.5, Behaviour 7).
233
235
  `capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
234
236
  resource limited, cache off); it returns a **new** context per call, as those workers
235
237
  inline the spec into every open today, and `capped_context_spec` exports the dict for
@@ -285,7 +287,8 @@ vol = wa.create(
285
287
  attrs=None, # extra array attributes, merged into the array's own
286
288
  group_attrs=None, # extra group attributes, merged; no one else's key is dropped or rewritten (Behaviour 12)
287
289
  exists="error", # "error": anything already at `path` raises | "adopt": an identical array (shape, dtype, chunks,
288
- # shards, placement within rtol 1e-6) is opened for writing; a different one raises naming each
290
+ # shards, placement within rtol 1e-6, and from 0.4 every key of `attrs` holding the value given)
291
+ # is opened for writing; a different one raises naming each
289
292
  # difference. There is no third value. An adopt writes no group document when the entry
290
293
  # already says the same (Behaviour 12)
291
294
  store_fill=False, # True stores write units equal to fill_value instead of leaving them absent
@@ -337,6 +340,32 @@ wa.store.join(path, *parts) ; wa.store.parent(path) ; wa.store.basename(path)
337
340
  wa.units.abbreviation(unit) -> str # the OME short form, "nm" for any spelling of nanometre the table knows
338
341
  ```
339
342
 
343
+ ### 0.4: reading onto a grid
344
+
345
+ ```python
346
+ vol.read_onto(grid, begin=None, end=None, padding=None, method=None, mask=False, retries=None, transform=None,
347
+ post_transform=None, select=None)
348
+ # voxels [begin, end) of a Grid's spatial axes (all of it when None), read from this array:
349
+ # exactly that shape, by the centre rule at the array's voxel size, else resampled onto the
350
+ # grid anchored at its corner (Behaviour 20)
351
+ vol.write_index(begin, end, 0) ; vol.write(box, 0) ; vol.write_chunk(index, 0)
352
+ # a scalar fills the box, converted to the dtype only when its value survives (Behaviour 13)
353
+ vol.source_translation # the stored level's translation in `units`, beside source_voxel_size, under resample and relabel
354
+ wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys of `attrs` (Behaviour 11)
355
+ ```
356
+
357
+ ### 0.5: writes that cannot hang
358
+
359
+ ```python
360
+ vol.write_index(begin, end, data, partial=False, retries=None)
361
+ vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
362
+ # timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
363
+ wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: OMP_NUM_THREADS, else
364
+ # LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
365
+ # every context the package makes or opens a store on is kicked once a second, whoever
366
+ # waits on it (Behaviour 6)
367
+ ```
368
+
340
369
  ## 3. Conventions
341
370
 
342
371
  These are the rules the package exists to hold in one place. They match chunkmirage's
@@ -604,25 +633,64 @@ this document and that one differ, fix one of them.
604
633
  `on_mismatch="relabel"` and picks `method` with its own `is_label_dtype` rule;
605
634
  cellmap-analyze passes `method` from its `interpolation_order`.
606
635
 
607
- 6. **Retries** apply to data reads only, with mesh-n-bone's formula, which its tests
608
- already pin: attempt `k` (from 1) waits `timeout * k` seconds on `.result()`, where
636
+ 6. **Retries** apply to data reads and data writes, with mesh-n-bone's formula, which its
637
+ tests already pin: attempt `k` (from 1) waits `timeout * k` seconds on `.result()`, where
609
638
  `timeout` is the given value or, when `None`, `clamp(MiB / 16, 5, 120)` for a remote
610
639
  store (`gs://`, `s3://`, `http(s)://`) and `clamp(MiB / 256, 5, 30)` for a local one
611
640
  (`file`, `memory`), `MiB` being the size of the in-bounds part of the read after
612
- clipping; between attempts the sleep is `base_delay * 1.3 ** (k - 1)` plus `U(0, 1)`
641
+ clipping, or of the box written; between attempts the sleep is `base_delay * 1.3 ** (k - 1)` plus `U(0, 1)`
613
642
  seconds; at most `max_attempts` (default 10). Retried: `TimeoutError`, and a tensorstore
614
643
  error whose message starts with `UNAVAILABLE`, `DEADLINE_EXCEEDED` or
615
644
  `RESOURCE_EXHAUSTED` or carries an HTTP 5xx or 429. Any other error is raised at once. A
616
- timed-out tensorstore future is cancelled before the next attempt starts. The policy is a
617
- per-handle attribute and a per-read argument, never only constructor state.
645
+ timed-out tensorstore future is cancelled before the next attempt starts; a write may
646
+ still land after its cancel, with the same bytes the next attempt writes. Each retry is
647
+ logged as a warning naming the error's first line, or its type when the message is
648
+ empty (tensorstore's timeout is `TimeoutError()`); when every attempt times out the
649
+ `TimeoutError` names the operation, the path and the attempts. Metadata documents are not
650
+ retried this way (Convention 5). The policy is a per-handle attribute and a per-read and
651
+ per-write argument, never only constructor state.
652
+
653
+ The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
654
+ worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
655
+ counts itself full (fixed upstream in google/tensorstore ed51718085, in no release yet).
656
+ The future then waits forever with no tensorstore thread alive, on a read, a write or an
657
+ open. A pool limited to one thread meets it most; cellmap-analyze 0.6 hung on /nrs with
658
+ `data_copy` and `file_io` at 1, and one more task on the same context woke the pool
659
+ within seconds (salamander's and c-elegans' live tests, 2026-10-22, with un-kicked
660
+ workers staying stuck). cellmap-analyze's read retries of June 2025 ("it sometimes
661
+ hangs") worked for the same reason: each attempt is a new task. So every context the
662
+ package makes (`shared_context`, `capped_context`, a rebuilt one) or opens a store on is
663
+ watched, weakly: one daemon thread, started on first use, gives each live watched
664
+ context's decoding and file I/O pools one no-op task every second (an in-memory array
665
+ copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
666
+ of CPU). A stranded task then runs within about a second whoever waits on it: the
667
+ package, `vol.ts` directly, or `await`, at any concurrency. The retry stays the outer
668
+ net. Open question 14 says when the watchdog goes.
618
669
 
619
670
  7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
620
- tensorstore's cache is off and the levels of one pyramid share one context.
671
+ tensorstore's cache is off and the levels of one pyramid share one context. Its
672
+ `data_copy_concurrency` and `file_io_concurrency` limits are `thread_budget()`: the
673
+ first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
674
+ sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
675
+ integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
676
+ in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
677
+ both pools to every CPU of the node, whatever the job's slots, so each of twelve dask
678
+ workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
679
+ worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
680
+ watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
681
+ `LSB_DJOB_NUMPROC` elsewhere. The cap is the default because the process that passes
682
+ no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
683
+ job came to start a thousand threads on twelve slots. One thread per pool slows one
684
+ task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
685
+ workers, whose throughput its cores bound either way (four writers: 1.8 s against
686
+ 1.9 s). A process that wants more threads than its job has slots, a server under
687
+ `bsub -n 1` say, asks for more slots or passes its own context (decided 2026-10-23,
688
+ over chunkmirage's objection, once the watchdog had removed the hang risk).
621
689
  `capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
622
690
  `gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
623
691
  `concurrency` and the cache to `cache_bytes`, and returns a new context each call, so two
624
- handles never share one capped pool unless the caller passes the same object twice. Both
625
- are plain tensorstore contexts; a caller may pass its own, at construction or through
692
+ handles never share one capped pool unless the caller passes the same object twice; a
693
+ `concurrency` of 1 is safe under Behaviour 6's watchdog. Both are plain tensorstore contexts; a caller may pass its own, at construction or through
626
694
  `reopen(context=)` when a long-lived worker changes its concurrency (cellmap-analyze
627
695
  reopens tensorstore on every read today for that reason).
628
696
 
@@ -682,9 +750,11 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
682
750
  compression; cellmap-flow's blosc stays blosc by passing it. There is no overwrite: with
683
751
  `exists="error"` any document already at `path` raises `FileExistsError`; with
684
752
  `exists="adopt"` an array whose shape, dtype, chunks, shards and placement match
685
- (placement within `rtol 1e-6`) is opened for writing and its dataset entry ensured, and
686
- one that differs raises `ValueError` listing every differing field with the value found
687
- and the value wanted (cellmap-flow's `_refuse_another_region`, registration-station's
753
+ (placement within `rtol 1e-6`), and whose attributes hold every key of `attrs` with the
754
+ value given as JSON stores it (keys not given are someone else's and not compared), is
755
+ opened for writing and its dataset entry ensured, and one that differs raises
756
+ `ValueError` listing every differing field and key with the value found and the value
757
+ wanted (cellmap-flow's `_refuse_another_region`, registration-station's
688
758
  missing check, which let a shorter last band's chunk grid win a creation race). A
689
759
  creation that loses a race, the store reporting the array present after the probe said
690
760
  absent, is re-read with backoff, 5 attempts at `0.5 s * attempt`, and then treated as an
@@ -697,7 +767,9 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
697
767
  being the one it replaces. `create` returns a writable `WorldArray`;
698
768
  `wa.open(path, mode="r+")` opens an existing one with every rule of Conventions 3 to 5
699
769
  unchanged, and a handle pickles by its arguments and its context's spec, so each worker
700
- process opens its own cheaply, on the shared context when the handle had one, else on
770
+ process opens its own cheaply (a local path goes resolved against the working directory
771
+ at open, so a worker that changes directory before unpickling, as cellmap-analyze's dask
772
+ workers do, opens the same store; `reopen` uses it too), on the shared context when the handle had one, else on
701
773
  a context rebuilt once per process from the same spec, so a capped context stays
702
774
  capped (mesh-n-bone's per-process cache and thread caps).
703
775
 
@@ -722,7 +794,12 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
722
794
 
723
795
  13. **Aligned writes need no coordination.** `write_index(begin, end, data)` requires
724
796
  `data.shape == end - begin`, `data.dtype == vol.dtype` (no silent cast; `ValueError`),
725
- and `[begin, end)` to be a union of whole write units: every bound a multiple of
797
+ or `data` a scalar (a number or a 0-d array) that fills the box: it is converted to the
798
+ dtype only when its value survives, so an integer or bool array takes whole numbers in
799
+ its range and a float array any number it does not overflow, rounded to its precision
800
+ (`0` into `uint8` is written; `300`, `-1`, `0.5` and NaN raise), and a scalar equal to
801
+ `fill_value` over whole units leaves them absent as an all-fill array does (cellmap-analyze
802
+ writes a constant for every empty or masked-out block). The box must be a union of whole write units: every bound a multiple of
726
803
  `write_shape` on its axis or the array's end on that axis, so the clipped last unit is
727
804
  allowed; otherwise `ValueError` names the axis, the bound and the unit size. A unit
728
805
  written whole is one put, so writers in different processes writing different units
@@ -730,7 +807,8 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
730
807
  construction. `partial=True` lifts the check and does tensorstore's read-modify-write
731
808
  of the touched units, documented as unsafe while anything else writes the same unit
732
809
  (cellmap-flow's paint slabs, which it serialises itself). A write commits before the
733
- call returns and is not retried by the package; `retries` applies to reads. `write(box,
810
+ call returns, timed out and retried by Behaviour 6 (0.5; before, it was not, and a
811
+ stranded write waited forever). `write(box,
734
812
  data)` maps a world box by Behaviour 2 and then calls `write_index`; a box reaching
735
813
  outside the array raises (nothing is padded on write) and `voxel_size` is not an
736
814
  argument (nothing is resampled on write).
@@ -809,6 +887,22 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
809
887
  returns the OME short form for any spelling the table knows and raises for one it does
810
888
  not.
811
889
 
890
+ 20. **Reading onto a grid.** `vol.read_onto(grid, begin, end)` returns voxels `[begin, end)`
891
+ of a `Grid`'s spatial axes, all of it when `begin` and `end` are None, read from this
892
+ array, with exactly `end - begin` voxels on those axes; the array's other axes are read
893
+ as `read` reads them (whole, or pinned by `select`), the open-time `padding`, `method`,
894
+ `retries` and `transform` apply, and `mask` marks the grid voxels that held data. It is
895
+ `read(Box.from_index(begin, end, grid voxel size, grid translation), voxel_size=grid
896
+ voxel size, anchor=grid corner)`, so Behaviours 2 and 4 decide every voxel: at the
897
+ array's own voxel size (within level selection's rtol 1e-6) nothing is resampled,
898
+ whatever the offset between the grids, and each grid voxel takes the stored voxel whose
899
+ centre lies in it, never an interpolation, with `method="linear"` too; at any other
900
+ voxel size the array is resampled onto the grid, anchored at its corner. Adjoining
901
+ `[begin, end)` reads therefore agree with one read across both. The grid's spatial axes
902
+ must be named as the array's are (`ValueError` otherwise), and its units are converted
903
+ to the array's `units`. This is cellmap-analyze's per-operation grid: blocks come from
904
+ one `Grid`, every input is read onto it, and outputs are created on it.
905
+
812
906
  ## 5. Tests (behavioural; synthetic stores in `tmp_path`; one optional network test)
813
907
 
814
908
  Conformance, one tiny store each, checking `voxel_size`, `translation`, `axes`,
@@ -898,8 +992,21 @@ factor; missing ancestor documents down from the `.zarr` root and none above it,
898
992
  ancestor's format followed and a contradiction refused, an array in the way refused; an
899
993
  adopt writing no document and a changed placement writing one; a lost entry written again
900
994
  and the error after the attempts; `ensure_group_entry` on a foreign array, idempotent; a
901
- pickled handle keeping a capped context's spec and sharing the shared one; `codecs_of` by
902
- path; an all-fill write removing its own key.
995
+ pickled handle keeping a capped context's spec and sharing the shared one, and a relative
996
+ path reopening after a `chdir`; `codecs_of` by path; an all-fill write removing its own key;
997
+ a scalar filling a box through `write_index`, `write` and `write_chunk`, refused when it
998
+ does not fit the dtype. 0.5: a read and a write whose first attempts never finish (a future
999
+ nothing resolves) cancelled and retried, the warning naming `TimeoutError`, and the error
1000
+ after the last attempt; a capped context and a caller's context passed to an open both
1001
+ kicked by the watchdog, a dead context no longer watched, and a kick landing on a capped
1002
+ context's own pools; `thread_budget` from each variable, a bad value skipped, and the
1003
+ shared context's limits. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1004
+ `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1005
+ worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1006
+ tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
1007
+ and a watched one strands none, its slowest task under a second. Run against cellmap-analyze's
1008
+ block loop (a chunk read, then a whole-chunk `write_index`, on its 0.6.1 context), 0.4.0
1009
+ gave 29 `IndexError`s and 55 writes stuck past 20 s, and 0.5.0 none, slowest 0.98 s.
903
1010
 
904
1011
  Grids (0.3): `blocks` partitioning with and without a box, `context` as an int, per axis
905
1012
  and as before/after pairs, clipping on and off, `len`, `blocks[id]` and `index_of`;
@@ -908,6 +1015,14 @@ and as before/after pairs, clipping on and off, `len`, `blocks[id]` and `index_o
908
1015
  reproducing mesh-n-bone's anisotropic table (8, 8, 30 nm: z stays at 1 until y and x
909
1016
  reach 32) and all 2s for isotropic data; `abbreviation` for every spelling in the table.
910
1017
 
1018
+ Reading onto a grid (0.4): `read_onto` at the array's voxel size returning stored values
1019
+ under a sub-voxel offset with `method="linear"`, and the next voxel past a half-voxel one;
1020
+ onto a finer grid equal to the `read` it is defined as, in micrometres too, adjoining
1021
+ reads agreeing, the mask marking grid voxels before the data; its own grid at voxel sizes
1022
+ 3.54, 4 and 5.3 returning the stored array exactly; a grid with other axis names refused.
1023
+ `source_translation` under relabel and resample and in micrometres; an adopt keeping keys
1024
+ it was not given and refusing a different or missing one, naming both values.
1025
+
911
1026
  Target: about 600 lines of test cases for 0.1 and about 300 more for 0.2 and 0.3, the
912
1027
  store builders in `conftest.py` aside; no wording pins; no `/nrs`; no cluster.
913
1028
 
@@ -1075,6 +1190,16 @@ over funlib geometry, by this spec's own assignment.
1075
1190
  boxes on one grid. `Grid.blocks` could carry world boxes too (`b.box`, `b.read_box` as
1076
1191
  `Box`, a context in world units). Two lines on cellmap-flow's side today. Not planned
1077
1192
  (2026-10-22): daisy does cellmap-flow's tiling and stays; reopen if cellmap-flow drops it.
1193
+ 14. **When the watchdog goes.** Behaviour 6's watchdog works around tensorstore's lost
1194
+ wakeup, fixed upstream in google/tensorstore ed51718085 (2026-09-30, "Fix lost wakeups
1195
+ in TaskGroup when workers block or exit") and in no release up to 0.1.85. When a release
1196
+ contains it (`gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` reports
1197
+ "ahead" or "identical"), confirm it with `WORLDARRAY_STRESS=1 pytest
1198
+ tests/test_lost_wakeup.py -s` (a plain context should strand nothing), require that
1199
+ release, remove `watch`, `kick` and the watchdog thread from `context.py`, and keep the
1200
+ write retries. That release
1201
+ needs Python 3.11 or later, as tensorstore has since 0.1.81, so it also ends support for
1202
+ 3.10.
1078
1203
 
1079
1204
  ## 8. Constraints for whoever builds on this
1080
1205
 
@@ -1084,10 +1209,14 @@ tests, minimal reusable code. The README is sections 1 to 5 of this file; a beha
1084
1209
  is not stated here is not implemented without adding the statement first.
1085
1210
 
1086
1211
  Versioning. Every consumer pins a floating range and its CI installs the newest version in
1087
- it, so a change in where any voxel sits is a **minor** release, never a patch: 0.1.1 moved
1088
- offset-less N5 pyramids by half a base voxel as a patch, and cellmap-flow's CI was red on
1089
- every open PR within the hour until it pinned 0.1.1. A patch release reads more stores or
1090
- reads them faster and places nothing differently; a minor release may move voxels or add
1091
- API, and its changelog entry starts with **Placement change** when it does; nothing before
1092
- 1.0 promises more. Consumers that cannot absorb a move between CI runs pin
1093
- `>=0.x,<0.(x+1)`.
1212
+ it within the hour of a release, so the version number says what a release can break. A
1213
+ **patch** release brings the code back to what this spec says, or reads more stores or
1214
+ reads them faster; it places nothing differently and adds nothing public. A **minor**
1215
+ release is anything else: it may move voxels or add API, and an entry added to, renamed in
1216
+ or removed from the unit table is API (`wa.units.TABLE` is public, and cellmap-flow exports
1217
+ it to its browser app behind a freshness test). Both were once shipped as patches and both
1218
+ turned cellmap-flow's CI red on every open PR within the hour: 0.1.1 moved offset-less N5
1219
+ pyramids by half a base voxel, and 0.3.1 added kilometres. A changelog entry starts with
1220
+ **Placement change** when voxels move and **Unit table change** when the table does, and the
1221
+ release message to each consumer repeats those lines; nothing before 1.0 promises more.
1222
+ Consumers that cannot absorb a move between CI runs pin `>=0.x,<0.(x+1)`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.3.1"
3
+ version = "0.5.0"
4
4
  description = "Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  from . import grid, store, units, write
14
14
  from .array import Pyramid, WorldArray, create, metadata, open, open_pyramid, open_tensorstore
15
- from .context import capped_context, capped_context_spec, shared_context
15
+ from .context import capped_context, capped_context_spec, shared_context, thread_budget
16
16
  from .geometry import Box, index_range
17
17
  from .grid import Block, Grid, blocks, level_factors
18
18
  from .levels import select_level
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.3.1"
31
+ __version__ = "0.5.0"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -62,6 +62,7 @@ __all__ = [
62
62
  "open_tensorstore",
63
63
  "select_level",
64
64
  "shared_context",
65
+ "thread_budget",
65
66
  "store",
66
67
  "units",
67
68
  "write",