worldarray 0.9.1__tar.gz → 0.10.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.9.1 → worldarray-0.10.0}/CHANGELOG.md +18 -0
  2. {worldarray-0.9.1 → worldarray-0.10.0}/PKG-INFO +5 -2
  3. {worldarray-0.9.1 → worldarray-0.10.0}/README.md +4 -1
  4. {worldarray-0.9.1 → worldarray-0.10.0}/docs/spec.md +45 -10
  5. {worldarray-0.9.1 → worldarray-0.10.0}/pyproject.toml +1 -1
  6. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/array.py +113 -20
  8. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/placement.py +79 -5
  9. worldarray-0.10.0/tests/test_axes_order.py +197 -0
  10. {worldarray-0.9.1 → worldarray-0.10.0}/uv.lock +1 -1
  11. {worldarray-0.9.1 → worldarray-0.10.0}/.github/workflows/ci.yml +0 -0
  12. {worldarray-0.9.1 → worldarray-0.10.0}/.github/workflows/docs.yml +0 -0
  13. {worldarray-0.9.1 → worldarray-0.10.0}/.github/workflows/publish.yml +0 -0
  14. {worldarray-0.9.1 → worldarray-0.10.0}/.gitignore +0 -0
  15. {worldarray-0.9.1 → worldarray-0.10.0}/LICENSE +0 -0
  16. {worldarray-0.9.1 → worldarray-0.10.0}/docs/changelog.md +0 -0
  17. {worldarray-0.9.1 → worldarray-0.10.0}/docs/index.md +0 -0
  18. {worldarray-0.9.1 → worldarray-0.10.0}/mkdocs.yml +0 -0
  19. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/context.py +0 -0
  20. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/geometry.py +0 -0
  21. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/grid.py +0 -0
  22. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/levels.py +0 -0
  23. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/resample.py +0 -0
  24. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/retry.py +0 -0
  25. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/store.py +0 -0
  26. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/units.py +0 -0
  27. {worldarray-0.9.1 → worldarray-0.10.0}/src/worldarray/write.py +0 -0
  28. {worldarray-0.9.1 → worldarray-0.10.0}/tests/conftest.py +0 -0
  29. {worldarray-0.9.1 → worldarray-0.10.0}/tests/fixtures/metadata.json +0 -0
  30. {worldarray-0.9.1 → worldarray-0.10.0}/tests/fixtures/write.json +0 -0
  31. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_access.py +0 -0
  32. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_geometry.py +0 -0
  33. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_grid.py +0 -0
  34. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_metadata.py +0 -0
  36. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_read.py +0 -0
  37. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_resample.py +0 -0
  38. {worldarray-0.9.1 → worldarray-0.10.0}/tests/test_write.py +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-11-03)
4
+
5
+ Axes in another order, on request. No placement change, no unit table change, and no
6
+ default changes: without `axes_order`, every array opens as it did in 0.9.
7
+
8
+ - `open`, `open_pyramid` and `metadata` take `axes_order`: `"stored"` (the default, for
9
+ good), `"zyx"` (the axes named z, y and x last, in that order, the others first in
10
+ stored order) or a sequence naming each axis once. An OME-Zarr written x, y, z
11
+ (TensorSwitch's) then reads as z, y, x, and a channel-last array channel first. The
12
+ names move with the data, and shape, chunks, shards, units, voxel size and
13
+ translation with them, in `.ts`, `meta`, `grid`, reads, writes, `blocks`, a pyramid's
14
+ placements and `level_meta`, level choice by voxel size, `base_voxel_size`, `reopen`
15
+ and a pickle. `open_tensorstore` stays in the store's order.
16
+ - On such a handle, `chunk_key`, `parse_chunk_key` and `list_chunks` take and give
17
+ indices in the presented order (a selected axis in its stored place); the keys are
18
+ the store's. `add_level` takes its per-axis arguments in the pyramid's order and
19
+ creates the array in the group's.
20
+
3
21
  ## 0.9.1 (2026-10-26)
4
22
 
5
23
  A fix for exit. No placement change, no unit table change, and no default changes.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.9.1
3
+ Version: 0.10.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/
@@ -116,6 +116,7 @@ vol = wa.open(path, units=None)
116
116
  vol.meta # shape, dtype, chunk_shape, axes, units, voxel_size, translation, attrs, ...
117
117
  vol.ts # the tensorstore itself, C order, origin 0
118
118
  vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
119
+ wa.open(path, axes_order="zyx") # an x, y, z store (TensorSwitch's) presented z, y, x, names and all
119
120
 
120
121
  pyr = wa.open_pyramid(path) # every level, opened on first use
121
122
  pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
@@ -192,7 +193,9 @@ Dialects that store the corner are moved half a voxel on reading:
192
193
  | precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
193
194
  | nothing | a plain array | voxels of 1, corner at the origin |
194
195
 
195
- Axes are the store's C order; N5 and precomputed are transposed to it. Unlisted or
196
+ Axes are the store's C order; N5 and precomputed are transposed to it. `axes_order="zyx"`
197
+ presents the axes named z, y and x last, in that order, for a reader that indexes axes by
198
+ position; the store's order stays the default. Unlisted or
196
199
  conflicting attributes warn; a voxel size that disagrees with the OME scale raises, since
197
200
  that is how stale metadata is caught. Metadata the parser did not consume stays available
198
201
  raw. The full precedence per format, the half-voxel rules, and every read behaviour are
@@ -57,6 +57,7 @@ vol = wa.open(path, units=None)
57
57
  vol.meta # shape, dtype, chunk_shape, axes, units, voxel_size, translation, attrs, ...
58
58
  vol.ts # the tensorstore itself, C order, origin 0
59
59
  vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
60
+ wa.open(path, axes_order="zyx") # an x, y, z store (TensorSwitch's) presented z, y, x, names and all
60
61
 
61
62
  pyr = wa.open_pyramid(path) # every level, opened on first use
62
63
  pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
@@ -133,7 +134,9 @@ Dialects that store the corner are moved half a voxel on reading:
133
134
  | precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
134
135
  | nothing | a plain array | voxels of 1, corner at the origin |
135
136
 
136
- Axes are the store's C order; N5 and precomputed are transposed to it. Unlisted or
137
+ Axes are the store's C order; N5 and precomputed are transposed to it. `axes_order="zyx"`
138
+ presents the axes named z, y and x last, in that order, for a reader that indexes axes by
139
+ position; the store's order stays the default. Unlisted or
137
140
  conflicting attributes warn; a voxel size that disagrees with the OME scale raises, since
138
141
  that is how stale metadata is caught. Metadata the parser did not consume stays available
139
142
  raw. The full precedence per format, the half-voxel rules, and every read behaviour are
@@ -131,6 +131,9 @@ vol = wa.open(
131
131
  # part of a box outside the data
132
132
  method="nearest", # "nearest" | "linear": the resampling kernel; no dtype heuristic (Behaviour 5)
133
133
  select=None, # {"c": 0, "t": 0}: pin non-spatial axes to one index; else they are kept whole
134
+ axes_order="stored", # "stored": the store's order (Convention 2) | "zyx": the axes named z, y and x last, in
135
+ # that order, the others first in stored order | a sequence naming each axis left after
136
+ # `select` once (0.10); every per-axis field and the data move with their names
134
137
  units="nanometer", # what world coordinates are in on spatial axes; a dict per axis kind
135
138
  # ({"space": "micrometer", "time": "second"}); None keeps the store's own units
136
139
  context=None, # a tensorstore.Context; default shared_context(0)
@@ -144,7 +147,7 @@ vol = wa.open(
144
147
  strict_units=True, # False: an unknown unit ("pixel") is taken as the target unit, with a warning
145
148
  )
146
149
 
147
- vol.ts # tensorstore.TensorStore: C-order axes, origin 0, selected axes dropped
150
+ vol.ts # tensorstore.TensorStore: the axes in `axes_order`, origin 0, selected axes dropped
148
151
  vol.meta # ArrayMeta in the store's own units
149
152
  vol.grid # ArrayMeta in the requested units, every axis
150
153
  vol.shape, vol.dtype, vol.axes, vol.spatial, vol.level_name, vol.levels, vol.remote
@@ -168,7 +171,7 @@ vol.index_box(box) -> (begin, end) # the voxels a world box selects on this gri
168
171
  vol.chunks_covering(begin, end) # chunk indices a voxel box touches
169
172
  vol.reopen(context=None, **overrides) -> WorldArray # the same array on another context (a worker changing its concurrency)
170
173
 
171
- pyr = wa.open_pyramid(path, context=None, units="nanometer", select=None)
174
+ pyr = wa.open_pyramid(path, context=None, units="nanometer", select=None, axes_order="stored")
172
175
  pyr.names; pyr.levels; pyr.by_name("s1"); pyr.select(voxel_size, mode="floor")
173
176
  pyr.metas() ; pyr.level_meta("s1") # ArrayMeta per level from the documents alone: shape, dtype, chunks (a sharded
174
177
  # array's inner chunk), fill and placement, with no tensorstore opened
@@ -177,11 +180,13 @@ pyr.voxel_sizes() ; pyr.translations() # where it lists the levels (OME-NGFF
177
180
  # choosing or placing a level opens nothing, whether the pyramid was opened by its
178
181
  # group path or by one of its level paths; else by opening each level
179
182
 
180
- wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta # open=False: from the documents alone
183
+ wa.metadata(path, level=None, units=None, open=True, retries=None, axes_order="stored") -> ArrayMeta
184
+ # open=False: from the documents alone
181
185
  wa.detect(path, missing_ok=False, retries=None) -> Node | None # format, kind (array|group), path, docs, scale_key; .attrs (raw user
182
186
  # attributes), .multiscales (OME 0.5's `ome` unwrapped)
183
187
  wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore.TensorStore # reads no world metadata, so an
184
- # array with odd placement attributes (a coordinate array) still opens
188
+ # array with odd placement attributes (a coordinate array) still opens;
189
+ # always the store's order (Convention 2)
185
190
  wa.corner_to_centre(corner, voxel_size, axes=None) ; wa.default_axes(ndim) ; wa.index_range(begin, end, voxel_size, translation)
186
191
  wa.units.TABLE # the unit tables, public: {"length": {...metres per unit}, "time": {...seconds per unit}}
187
192
  wa.select_level(levels, voxel_size, mode) ; wa.Box ; wa.Retry(max_attempts=10, timeout=None, base_delay=1.0)
@@ -196,7 +201,7 @@ wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present und
196
201
  # metadata document needed, so a directory of chunk files with no .zarray lists too
197
202
  ```
198
203
 
199
- `ArrayMeta` is a frozen dataclass with one entry per axis, in the array's C order, and
204
+ `ArrayMeta` is a frozen dataclass with one entry per axis, in the array's order (Convention 2), and
200
205
  deliberately the same field names as chunkmirage's `ArrayInfo`: `shape`, `dtype`,
201
206
  `chunk_shape` (tensorstore's read chunk), `axes` (as stored; `("t","c","z","y","x")[-ndim:]`
202
207
  when the store names none), `axes_named`, `units` (as stored, any string; `""` when none),
@@ -276,7 +281,8 @@ wa.ensure_group_entry(group_path, name, voxel_size, translation, axes, units="na
276
281
  # document and missing ancestors created, nothing written when the entry already says the same
277
282
  # (Behaviour 12). `create` and `add_level` call this
278
283
  wa.write.codecs_of(path_or_node) -> (codecs, shards) # an array's codecs in its format's dialect and its shard shape, for
279
- # a mirror created like it (cellmap-flow's sync)
284
+ # a mirror created like it (cellmap-flow's sync); the shards in the store's order, so a
285
+ # mirror of a handle opened with `axes_order` takes vol.meta.shard_shape (Grid.from_meta does)
280
286
 
281
287
  vol = wa.create(
282
288
  path, # ".../vol.zarr/s0": the last component is the level, its parent the multiscale group, made if absent
@@ -422,9 +428,21 @@ this document and that one differ, fix one of them.
422
428
 
423
429
  2. **Axis order is the store's C order; N5 and precomputed are transposed to it** on read,
424
430
  so `(z, y, x)` for a volume, `(c, z, y, x)` with channels, `(t, c, z, y, x)` for OME
425
- 0.5. Nothing is reordered beyond that transposition; `select` pins non-spatial axes. A
431
+ 0.5. Nothing is reordered beyond that transposition unless asked. `axes_order="zyx"`
432
+ (0.10, on `open`, `open_pyramid` and `metadata`) presents the axes named z, y and x last,
433
+ in that order, and the others first in their stored order, so an OME-Zarr written x, y, z
434
+ (TensorSwitch's) reads as z, y, x and a channel-last array channel first; an array naming
435
+ none of z, y and x is unchanged. A sequence of names presents exactly that order and must
436
+ name each axis left after `select` once. The names move with the data, and shape, chunks,
437
+ shards, units, voxel size and translation with them, in `.ts`, `meta`, `grid`, every read
438
+ and write, the pyramid's placements and `level_meta` (where a selected axis, still there,
439
+ keeps its stored place) and a pickle; `open_tensorstore` stays in the store's order. The
440
+ default is the store's order for good, not only until 1.0: an x, y, z store read
441
+ faithfully is x, y, z with its names, which a reader placing axes by name needs
442
+ (Neuroglancer, chunkmirage, a mirror), and a reader indexing axes by position asks for its
443
+ order or checks the names (David, 2026-11-03). `select` pins non-spatial axes. A
426
444
  non-zero stored origin (precomputed `voxel_offset`) is moved to 0 in `.ts` and carried in
427
- `translation`. \1 Sharded stores (zarr v3 `sharding_indexed`, sharded precomputed) read like any
445
+ `translation`. Sharded stores (zarr v3 `sharding_indexed`, sharded precomputed) read like any
428
446
  other through tensorstore; `chunk_shape` is the inner read chunk, not the shard. Unnamed axes are called `("t", "c", "z", "y", "x")[-ndim:]` (chunkmirage's
429
447
  default) with `axes_named` False, so a consumer can apply its own default instead.
430
448
 
@@ -910,6 +928,10 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
910
928
  name is replaced; the group's other content is untouched (Behaviour 12). It writes no
911
929
  voxel data: downsampling is the caller's, per block, through `write_index`, labels by
912
930
  mode and images by mean as each repo decides. `exists` means what it means for `create`.
931
+ On a pyramid opened with `axes_order` (0.10), the per-axis arguments are in the order it
932
+ presents (`factors`, `origin`, `voxel_size` and `translation` on its spatial axes, `shape`,
933
+ `chunks` and `shards` as `level_meta` lists them), the array is created in the group's own
934
+ order, and the level comes back in the pyramid's.
913
935
 
914
936
  15. **Chunk keys.** `chunk_key(index)` is the key, relative to the array, of the write unit
915
937
  at `index`: zarr v2 indices joined by the array's `dimension_separator` (`0.1.2`); zarr
@@ -921,7 +943,11 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
921
943
  (`zarr.json`, `.zarray`, `attributes.json`, `info`, a `c/` key of the wrong arity).
922
944
  `list_chunk_keys` and `list_chunks` use the same parser. The free functions take the
923
945
  format and the array's facts instead of an open array, for an array served from nowhere
924
- (chunkmirage's frontends) or about to be created.
946
+ (chunkmirage's frontends) or about to be created. On a handle opened with `axes_order`
947
+ (0.10), `chunk_key`, `parse_chunk_key` and `list_chunks` take and give indices in the
948
+ order the handle presents, a selected axis in its stored place, as an N5 index is in C
949
+ order and its key x first; the key is the store's. The free functions stay in the
950
+ store's order.
925
951
 
926
952
  16. **Write units.** `write_shape` is the shard shape of a sharded zarr v3 array, else the
927
953
  chunk shape; `ArrayMeta.shard_shape` is None when unsharded; `chunk_shape` stays the
@@ -1098,7 +1124,16 @@ array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other var
1098
1124
  blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
1099
1125
  ignored with a thread count, and carried through a pickle; a handle pickled on the driver
1100
1126
  sized in a spawned worker by that worker's `WORLDARRAY_THREADS`. 0.9.1: a process whose
1101
- finalization takes a while, exiting while the watchdog kicks flat out, exits cleanly.
1127
+ finalization takes a while, exiting while the watchdog kicks flat out, exits cleanly. 0.10:
1128
+ an x, y, z array and a c, x, y, z one opened with `axes_order="zyx"` reading as the
1129
+ transpose of the stored data with every per-axis field moved, a write through the
1130
+ reordered view landing in the stored array's order, its chunk keys unchanged and its
1131
+ indices presented; a sequence order, and a bad order refused; an N5 array and an
1132
+ unnamed one unchanged under "zyx"; a selected channel keeping its place in a key index;
1133
+ `metadata` with and without opening; a pyramid's placements and `level_meta` reordered, a
1134
+ level chosen by an anisotropic voxel size, `base_voxel_size` applied to the right axes, and
1135
+ `add_level` taking its factors in the presented order; the order carried through
1136
+ `reopen` and a pickle.
1102
1137
  Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1103
1138
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1104
1139
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.9.1"
3
+ version = "0.10.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" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.9.1"
31
+ __version__ = "0.10.0"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -29,11 +29,16 @@ from .geometry import Box, index_range, is_aligned
29
29
  from .levels import LevelRef, Mode, discover, discover_group, same_voxel_size, select_level
30
30
  from .placement import (
31
31
  ArrayMeta,
32
+ AxesOrder,
32
33
  _dims_from_docs,
34
+ axes_permutation,
35
+ check_axes_order,
33
36
  default_axes,
34
37
  light_levels,
35
38
  read_meta,
36
39
  read_meta_light,
40
+ spatial_axes,
41
+ spatial_order,
37
42
  )
38
43
  from .retry import Retry, read_with_retries, write_with_retries
39
44
  from .store import (
@@ -92,6 +97,7 @@ class WorldArray:
92
97
  strict_units: bool = True,
93
98
  mode: str = "r",
94
99
  store_fill: bool = False,
100
+ axes_order: AxesOrder = "stored",
95
101
  _ref: LevelRef | None = None,
96
102
  _node: Node | None = None,
97
103
  ):
@@ -112,6 +118,7 @@ class WorldArray:
112
118
  self.context = context if context is not None else shared_context(0)
113
119
  self.retries = retries if retries is not None else Retry()
114
120
  self._select = dict(select or {})
121
+ self._axes_order = check_axes_order(axes_order)
115
122
  self._opts = dict(
116
123
  level=level,
117
124
  voxel_size=voxel_size,
@@ -124,6 +131,7 @@ class WorldArray:
124
131
  strict_units=strict_units,
125
132
  mode=mode,
126
133
  store_fill=store_fill,
134
+ axes_order=self._axes_order,
127
135
  )
128
136
  self._anchor = tuple(float(a) for a in anchor) if anchor is not None else None
129
137
 
@@ -147,7 +155,21 @@ class WorldArray:
147
155
  self._meta = read_meta(
148
156
  node_for_level, self._store, scale_index=ref.scale_index, conflicts=metadata_conflicts
149
157
  )
158
+ stored_axes = self._meta.axes
150
159
  self._apply_select()
160
+ # the order the axes are presented in (Conventions 2), and the stored axis at each
161
+ # place of a chunk key's index: a selected axis keeps its stored place there
162
+ order = axes_permutation(self._meta.axes, self._axes_order)
163
+ if order != tuple(range(len(order))):
164
+ self._store = self._store[ts.d[order].transpose[:]]
165
+ self._meta = self._meta.permuted(order)
166
+ if node_for_level.format == "precomputed": # a precomputed key names the spatial chunk
167
+ sp = spatial_axes(stored_axes)
168
+ self._key_order = tuple(
169
+ sp.index(i) for i in spatial_order(stored_axes, self._meta.axes)
170
+ )
171
+ else:
172
+ self._key_order = axes_permutation(stored_axes, self._meta.axes, fixed=self._select)
151
173
  if base_voxel_size is not None:
152
174
  self._meta = self._rescaled_to_base(base_voxel_size, base_units)
153
175
  space, time = _unit_targets(units)
@@ -198,7 +220,12 @@ class WorldArray:
198
220
  warnings.warn(f"{self.path} is an array; level={level!r} is ignored", stacklevel=3)
199
221
  return LevelRef(node.path.rsplit("/", 1)[-1], node.path) # the array itself, always
200
222
  self._pyramid = Pyramid(
201
- self.path, context=self.context, units=self.units, select=self._select, _node=node
223
+ self.path,
224
+ context=self.context,
225
+ units=self.units,
226
+ select=self._select,
227
+ axes_order=self._axes_order,
228
+ _node=node,
202
229
  )
203
230
  if level is not None:
204
231
  return self._pyramid._ref(level)
@@ -227,7 +254,13 @@ class WorldArray:
227
254
  by the same per-axis factor, so stores whose metadata is in voxels of level 0 (as
228
255
  BigStitcher writes) or unitless (a BigDataViewer N5) can be placed correctly."""
229
256
  base = tuple(float(v) for v in base)
230
- native = Pyramid(self.path, context=self.context, units=None, select=self._select)
257
+ native = Pyramid(
258
+ self.path,
259
+ context=self.context,
260
+ units=None,
261
+ select=self._select,
262
+ axes_order=self._axes_order,
263
+ )
231
264
  level0 = native.levels[0].meta if len(native) > 1 else self._meta
232
265
  spatial = self._meta.spatial
233
266
  if len(base) != len(spatial):
@@ -394,7 +427,11 @@ class WorldArray:
394
427
  def levels_pyramid(self) -> Pyramid:
395
428
  if self._pyramid is None:
396
429
  self._pyramid = Pyramid(
397
- self._where, context=self.context, units=self.units, select=self._select
430
+ self._where,
431
+ context=self.context,
432
+ units=self.units,
433
+ select=self._select,
434
+ axes_order=self._axes_order,
398
435
  )
399
436
  return self._pyramid
400
437
 
@@ -507,19 +544,37 @@ class WorldArray:
507
544
  params["origin"] = tuple(int(o) for o in scale.get("voxel_offset", [0, 0, 0]))[::-1]
508
545
  return params
509
546
 
547
+ def _stored_index(self, index: Sequence[int]) -> tuple[int, ...]:
548
+ """A key index in the presented order, in the store's."""
549
+ index, order = tuple(index), self._key_order
550
+ if len(index) != len(order):
551
+ return index # the key function says what is wrong
552
+ stored = [0] * len(order)
553
+ for place, axis in enumerate(order):
554
+ stored[axis] = index[place]
555
+ return tuple(stored)
556
+
557
+ def _presented_index(self, index: tuple[int, ...] | None) -> tuple[int, ...] | None:
558
+ """A key index in the store's order, in the presented one."""
559
+ if index is None or len(index) != len(self._key_order):
560
+ return index
561
+ return tuple(index[axis] for axis in self._key_order)
562
+
510
563
  def chunk_key(self, index: Sequence[int]) -> str:
511
564
  """The storage key, relative to the array, of the write unit at ``index`` on the
512
- storage grid (Behaviour 15). A precomputed array's key names the spatial chunk only."""
513
- return _write.chunk_key(index, **self._key_params())
565
+ storage grid, in the order the axes are presented (Behaviour 15). A precomputed
566
+ array's key names the spatial chunk only."""
567
+ return _write.chunk_key(self._stored_index(index), **self._key_params())
514
568
 
515
569
  def parse_chunk_key(self, key: str) -> tuple[int, ...] | None:
516
570
  """``chunk_key`` inverted; None for a key that is not a chunk."""
517
- return _write.parse_chunk_key(key, **self._key_params())
571
+ return self._presented_index(_write.parse_chunk_key(key, **self._key_params()))
518
572
 
519
573
  def list_chunks(self) -> list[tuple[int, ...]]:
520
- """The chunk indices that exist in storage (C order, the storage grid), by listing the
521
- array's keys with ``parse_chunk_key``: zarr v2 and v3 (a sharded array lists its
522
- shards), N5, and unsharded precomputed. Works on any kvstore, URLs included."""
574
+ """The chunk indices that exist in storage (the storage grid, in the order the axes
575
+ are presented), by listing the array's keys with ``parse_chunk_key``: zarr v2 and v3
576
+ (a sharded array lists its shards), N5, and unsharded precomputed. Works on any
577
+ kvstore, URLs included."""
523
578
  params = self._key_params()
524
579
  kv = ts.KvStore.open(self._store.kvstore.spec()).result()
525
580
  keys = [k.decode() if isinstance(k, bytes) else str(k) for k in kv.list().result()]
@@ -529,7 +584,7 @@ class WorldArray:
529
584
  k.rsplit("/", 1)[-1] if params["format"] == "precomputed" else k, **params
530
585
  )
531
586
  if idx is not None:
532
- found.add(idx)
587
+ found.add(self._presented_index(idx))
533
588
  return sorted(found)
534
589
 
535
590
  # --- writing (Behaviour 13) ----------------------------------------------------
@@ -1013,6 +1068,7 @@ class Pyramid:
1013
1068
  units: Units = "nanometer",
1014
1069
  select: dict[str, int] | None = None,
1015
1070
  mode: str = "r",
1071
+ axes_order: AxesOrder = "stored",
1016
1072
  _node: Node | None = None,
1017
1073
  **kwargs,
1018
1074
  ):
@@ -1023,7 +1079,8 @@ class Pyramid:
1023
1079
  self.units = units
1024
1080
  self._select = select or {}
1025
1081
  self._mode = mode
1026
- self._kwargs = {**kwargs, "mode": mode}
1082
+ self._axes_order = check_axes_order(axes_order)
1083
+ self._kwargs = {**kwargs, "mode": mode, "axes_order": self._axes_order}
1027
1084
  self._node = _node if _node is not None else detect(path)
1028
1085
  self._group, self._refs = discover_group(self._node) # the node listing the levels
1029
1086
  if not self._refs:
@@ -1079,7 +1136,11 @@ class Pyramid:
1079
1136
  space, _ = _unit_targets(self.units)
1080
1137
  # the group the levels were discovered from, whichever path opened the pyramid
1081
1138
  group = self._group
1082
- light = light_levels(group, self.names, space) if group is not None else None
1139
+ light = (
1140
+ light_levels(group, self.names, space, self._axes_order)
1141
+ if group is not None
1142
+ else None
1143
+ )
1083
1144
  if light is None:
1084
1145
  light = [
1085
1146
  (lvl.level_name, lvl.source_voxel_size, lvl.translation) for lvl in self.levels
@@ -1090,12 +1151,15 @@ class Pyramid:
1090
1151
  def level_meta(self, level: str | int) -> ArrayMeta:
1091
1152
  """One level's ``ArrayMeta`` in native units from its documents alone, with no
1092
1153
  tensorstore opened (shape, dtype, chunks and placement; a sharded array's inner
1093
- chunk)."""
1154
+ chunk), every axis in the order the pyramid presents them, a selected one in its
1155
+ stored place."""
1094
1156
  ref = self._ref(level)
1095
1157
  conflicts = self._kwargs.get("metadata_conflicts", "error")
1096
1158
  if ref.scale_index is not None:
1097
- return read_meta_light(self._node, scale_index=ref.scale_index, conflicts=conflicts)
1098
- return read_meta_light(detect(ref.path), conflicts=conflicts)
1159
+ meta = read_meta_light(self._node, scale_index=ref.scale_index, conflicts=conflicts)
1160
+ else:
1161
+ meta = read_meta_light(detect(ref.path), conflicts=conflicts)
1162
+ return meta.permuted(axes_permutation(meta.axes, self._axes_order, fixed=self._select))
1099
1163
 
1100
1164
  def metas(self) -> list[ArrayMeta]:
1101
1165
  """``level_meta`` for every level, in order."""
@@ -1141,6 +1205,12 @@ class Pyramid:
1141
1205
  base_node = detect(base_ref.path)
1142
1206
  base = read_meta_light(base_node, conflicts=self._kwargs.get("metadata_conflicts", "error"))
1143
1207
  ndim, spatial = base.ndim, base.spatial
1208
+ # the arguments are in the order the pyramid presents the axes; the array is the group's
1209
+ order = axes_permutation(base.axes, self._axes_order, fixed=self._select)
1210
+ factors, origin, voxel_size, translation, shape, chunks, shards = (
1211
+ _in_stored_order(v, order, spatial)
1212
+ for v in (factors, origin, voxel_size, translation, shape, chunks, shards)
1213
+ )
1144
1214
  if voxel_size is None or translation is None:
1145
1215
  if factors is None:
1146
1216
  raise ValueError("pass factors (and origin), or voxel_size and translation")
@@ -1184,7 +1254,7 @@ class Pyramid:
1184
1254
  )
1185
1255
  self._group, self._refs = discover_group(detect(group.path))
1186
1256
  self._light, self._levels = None, {}
1187
- return vol
1257
+ return vol if self._axes_order == "stored" else vol.reopen(axes_order=self._axes_order)
1188
1258
 
1189
1259
  def _select_ref(self, voxel_size, mode: Mode = "floor") -> LevelRef:
1190
1260
  name, _ = select_level(self.voxel_sizes(), voxel_size, mode)
@@ -1337,6 +1407,27 @@ def create(
1337
1407
  )
1338
1408
 
1339
1409
 
1410
+ def _in_stored_order(values, order: tuple[int, ...], spatial: tuple[int, ...]):
1411
+ """Per-axis ``values`` given in a presented order (``order[k]`` is the stored axis at
1412
+ place ``k``), in the store's: one per axis, or one per spatial axis. A scalar, None or a
1413
+ list of another length passes through."""
1414
+ if values is None or np.isscalar(values):
1415
+ return values
1416
+ values = tuple(values)
1417
+ if len(values) == len(order):
1418
+ stored = [None] * len(order)
1419
+ for place, axis in enumerate(order):
1420
+ stored[axis] = values[place]
1421
+ return tuple(stored)
1422
+ presented = [axis for axis in order if axis in spatial]
1423
+ if len(values) == len(presented):
1424
+ stored = [None] * len(spatial)
1425
+ for place, axis in enumerate(presented):
1426
+ stored[spatial.index(axis)] = values[place]
1427
+ return tuple(stored)
1428
+ return values
1429
+
1430
+
1340
1431
  def _reopen(path: str, opts: dict, recipe=None) -> WorldArray:
1341
1432
  return WorldArray(path, context=from_recipe(recipe), **opts)
1342
1433
 
@@ -1357,13 +1448,14 @@ def metadata(
1357
1448
  units: Units = None,
1358
1449
  open: bool = True,
1359
1450
  retries: Retry | None = None,
1451
+ axes_order: AxesOrder = "stored",
1360
1452
  ) -> ArrayMeta:
1361
1453
  """The ``ArrayMeta`` of an array (or of ``level`` under a group), in native units unless
1362
- ``units`` says otherwise. ``open=False`` reads it from the documents alone, with no
1363
- tensorstore opened. ``retries`` times out and retries each document read and the open
1364
- (Behaviour 6)."""
1454
+ ``units`` says otherwise, its axes in ``axes_order`` (Conventions 2). ``open=False``
1455
+ reads it from the documents alone, with no tensorstore opened. ``retries`` times out and
1456
+ retries each document read and the open (Behaviour 6)."""
1365
1457
  if open:
1366
- vol = WorldArray(path, level=level, units=units, retries=retries)
1458
+ vol = WorldArray(path, level=level, units=units, retries=retries, axes_order=axes_order)
1367
1459
  return vol.meta if units is None else vol.grid
1368
1460
  node = detect(path)
1369
1461
  if level == "first":
@@ -1391,6 +1483,7 @@ def metadata(
1391
1483
  meta = read_meta_light(detect(ref.path))
1392
1484
  else:
1393
1485
  meta = read_meta_light(node)
1486
+ meta = meta.permuted(axes_permutation(meta.axes, axes_order))
1394
1487
  if units is None:
1395
1488
  return meta
1396
1489
  space, time = _unit_targets(units)
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import json
18
18
  import logging
19
19
  import re
20
+ from collections.abc import Sequence
20
21
  from dataclasses import dataclass, field, replace
21
22
 
22
23
  import numpy as np
@@ -143,12 +144,82 @@ class ArrayMeta:
143
144
  shard_shape=pick(self.shard_shape) if self.shard_shape is not None else None,
144
145
  )
145
146
 
147
+ def permuted(self, order: Sequence[int]) -> ArrayMeta:
148
+ """The same metadata with axis ``order[k]`` at place ``k``: every per-axis field moves
149
+ with its name."""
150
+ pick = lambda t: tuple(t[i] for i in order) # noqa: E731
151
+ return replace(
152
+ self,
153
+ shape=pick(self.shape),
154
+ chunk_shape=pick(self.chunk_shape),
155
+ axes=pick(self.axes),
156
+ units=pick(self.units),
157
+ voxel_size=pick(self.voxel_size),
158
+ translation=pick(self.translation),
159
+ shard_shape=pick(self.shard_shape) if self.shard_shape is not None else None,
160
+ )
161
+
146
162
  @property
147
163
  def write_shape(self) -> tuple[int, ...]:
148
164
  """The unit one writer may own: the shard shape when sharded, else the chunk shape."""
149
165
  return self.shard_shape if self.shard_shape is not None else self.chunk_shape
150
166
 
151
167
 
168
+ AxesOrder = str | Sequence[str]
169
+
170
+
171
+ def check_axes_order(order: AxesOrder) -> str | tuple[str, ...]:
172
+ """``"stored"``, ``"zyx"`` or a tuple of axis names (Conventions 2)."""
173
+ if isinstance(order, str):
174
+ if order not in ("stored", "zyx"):
175
+ raise ValueError(
176
+ f"axes_order is 'stored', 'zyx' or a sequence of axis names, not {order!r}"
177
+ )
178
+ return order
179
+ names = tuple(str(a) for a in order)
180
+ if len(set(names)) != len(names):
181
+ raise ValueError(f"axes_order {names} names an axis twice")
182
+ return names
183
+
184
+
185
+ def axes_permutation(
186
+ axes: Sequence[str], order: AxesOrder = "stored", fixed: Sequence[str] = ()
187
+ ) -> tuple[int, ...]:
188
+ """The axis of ``axes`` at each place once ``order`` presents them (Conventions 2):
189
+ ``"stored"`` keeps them; ``"zyx"`` moves the axes named z, y and x last, in that order,
190
+ the others first in stored order; a sequence of names is that order. Axes named in
191
+ ``fixed`` (selected ones, still in a key index or a pyramid's ``level_meta``) keep their
192
+ places, and the others fill the rest in order."""
193
+ axes = tuple(axes)
194
+ order = check_axes_order(order)
195
+ movable = [i for i, a in enumerate(axes) if a not in fixed]
196
+ if order == "stored":
197
+ ranked = movable
198
+ elif order == "zyx":
199
+ named = sorted(
200
+ (i for i in movable if axes[i] in SPATIAL_NAMES), key=lambda i: "zyx".index(axes[i])
201
+ )
202
+ ranked = [i for i in movable if axes[i] not in SPATIAL_NAMES] + named
203
+ else:
204
+ names = [axes[i] for i in movable]
205
+ if sorted(order) != sorted(names):
206
+ raise ValueError(f"axes_order {order} must name each of the axes {tuple(names)} once")
207
+ ranked = [movable[names.index(a)] for a in order]
208
+ places = iter(ranked)
209
+ return tuple(i if axes[i] in fixed else next(places) for i in range(len(axes)))
210
+
211
+
212
+ def spatial_order(axes: Sequence[str], order: AxesOrder = "stored") -> tuple[int, ...]:
213
+ """The spatial axes of ``axes`` in the order ``order`` presents them."""
214
+ axes = tuple(axes)
215
+ sp = spatial_axes(axes)
216
+ others = tuple(a for i, a in enumerate(axes) if i not in sp)
217
+ order = check_axes_order(order)
218
+ if not isinstance(order, str):
219
+ order = tuple(a for a in order if a in {axes[i] for i in sp})
220
+ return tuple(i for i in axes_permutation(axes, order, fixed=others) if i in sp)
221
+
222
+
152
223
  def spatial_axes(axes: tuple[str, ...]) -> tuple[int, ...]:
153
224
  named = tuple(i for i, a in enumerate(axes) if a in SPATIAL_NAMES)
154
225
  if named:
@@ -730,29 +801,32 @@ def _precomputed(info: dict, scale_index: int, ndim: int) -> _Resolved:
730
801
 
731
802
 
732
803
  def light_voxel_sizes(
733
- group: Node, names: list[str], space: str | None
804
+ group: Node, names: list[str], space: str | None, axes_order: AxesOrder = "stored"
734
805
  ) -> list[tuple[str, tuple[float, ...]]] | None:
735
- levels = light_levels(group, names, space)
806
+ levels = light_levels(group, names, space, axes_order)
736
807
  return None if levels is None else [(name, vs) for name, vs, _ in levels]
737
808
 
738
809
 
739
810
  def light_levels(
740
- group: Node, names: list[str], space: str | None
811
+ group: Node, names: list[str], space: str | None, axes_order: AxesOrder = "stored"
741
812
  ) -> list[tuple[str, tuple[float, ...], tuple[float, ...]]] | None:
742
813
  """``(name, spatial voxel size)`` for every level of a group, from the group's own
743
814
  document, in ``space`` units (native when None): OME-NGFF datasets, COSEM N5
744
815
  ``multiscales[].datasets[].transform`` and precomputed ``scales``. None when the
745
816
  document does not say (then the levels have to be opened), or when a unit cannot be
746
817
  converted. ``light_levels`` adds each level's spatial translation (the centre of its
747
- voxel 0, in the same units), so a pyramid can be placed without opening any level."""
818
+ voxel 0, in the same units), so a pyramid can be placed without opening any level.
819
+ Both on the spatial axes in the order ``axes_order`` presents them."""
748
820
  try:
749
821
  if group.format == "precomputed":
750
822
  f = U.factor("nm", space) if space else 1.0
823
+ order = spatial_order(("z", "y", "x"), axes_order)
751
824
  out = []
752
825
  for i, scale in enumerate(group.docs["info"]["scales"]):
753
826
  res = [float(v) * f for v in scale["resolution"]][::-1]
754
827
  vo = [float(v) for v in scale.get("voxel_offset", [0, 0, 0])][::-1]
755
828
  tr = [(o + 0.5) * r for o, r in zip(vo, res)]
829
+ res, tr = [res[k] for k in order], [tr[k] for k in order]
756
830
  out.append((str(scale.get("key", i)), tuple(res), tuple(tr)))
757
831
  return out
758
832
  multiscales = group.multiscales
@@ -788,7 +862,7 @@ def light_levels(
788
862
  axes = found.axes or default_axes(ndim)
789
863
  units = found.units or ("",) * ndim
790
864
  sizes, trs = [], []
791
- for i in spatial_axes(axes):
865
+ for i in spatial_order(axes, axes_order):
792
866
  f = U.factor(units[i], space, missing_as=U.NANOMETER) if space else 1.0
793
867
  sizes.append(found.voxel_size[i] * f)
794
868
  trs.append((found.translation[i] if found.translation is not None else 0.0) * f)
@@ -0,0 +1,197 @@
1
+ """Presenting the axes in another order: ``axes_order`` (spec, Conventions 2, Behaviours 14
2
+ and 15)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import pickle
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ import worldarray as wa
12
+ from conftest import pattern, write_n5, write_zarr2
13
+
14
+ XYZ = ("x", "y", "z")
15
+
16
+
17
+ def _xyz(tmp_path, shape=(8, 6, 4), **kw) -> tuple[str, np.ndarray]:
18
+ """An x, y, z array (TensorSwitch's order) at 16, 16, 45 nm, filled with a pattern."""
19
+ path = str(tmp_path / "xyz.zarr/s0")
20
+ vol = wa.create(path, shape, "uint16", (16, 16, 45), (4, 4, 0), axes=XYZ, **kw)
21
+ data = pattern(shape)
22
+ vol.write_index((0,) * len(shape), shape, data)
23
+ return path, data
24
+
25
+
26
+ def test_zyx_presents_an_xyz_array_as_its_transpose(tmp_path):
27
+ path, data = _xyz(tmp_path, chunks=(4, 3, 2))
28
+ stored = wa.open(path)
29
+ assert stored.axes == XYZ and stored.shape == (8, 6, 4) # the default is the store's order
30
+ vol = wa.open(path, axes_order="zyx")
31
+ assert vol.axes == ("z", "y", "x") and vol.shape == (4, 6, 8) and vol.ts.shape == (4, 6, 8)
32
+ assert vol.voxel_size == (45, 16, 16) and vol.translation == (0, 4, 4)
33
+ assert vol.meta.chunk_shape == (2, 3, 4) and vol.write_shape == (2, 3, 4)
34
+ assert vol.meta.units == ("nanometer",) * 3
35
+ np.testing.assert_array_equal(vol.read_index((0, 0, 0), (4, 6, 8)), data.transpose(2, 1, 0))
36
+ np.testing.assert_array_equal(vol.read(vol.box), data.transpose(2, 1, 0))
37
+ part = vol.read_index((1, 2, 3), (3, 5, 7))
38
+ np.testing.assert_array_equal(part, data[3:7, 2:5, 1:3].transpose(2, 1, 0))
39
+ assert wa.Grid.from_meta(vol.meta).axes == ("z", "y", "x")
40
+
41
+
42
+ def test_a_channel_axis_comes_first_and_select_still_pins_it(tmp_path):
43
+ path = str(tmp_path / "cxyz.zarr/s0")
44
+ data = pattern((2, 8, 6, 4))
45
+ wa.create(
46
+ path, data.shape, "uint16", (16, 16, 45), (4, 4, 0), axes=("c", *XYZ), chunks=(1, 4, 3, 2)
47
+ )
48
+ wa.open(path, mode="r+").write_index((0,) * 4, data.shape, data)
49
+ vol = wa.open(path, axes_order="zyx")
50
+ assert vol.axes == ("c", "z", "y", "x") and vol.meta.chunk_shape == (1, 2, 3, 4)
51
+ np.testing.assert_array_equal(vol.read_index((0,) * 4, vol.shape), data.transpose(0, 3, 2, 1))
52
+ one = wa.open(path, axes_order="zyx", select={"c": 1})
53
+ assert one.axes == ("z", "y", "x")
54
+ np.testing.assert_array_equal(one.read_index((0,) * 3, one.shape), data[1].transpose(2, 1, 0))
55
+ # a channel-last array comes out channel first
56
+ last = str(tmp_path / "xyzc.zarr/s0")
57
+ wa.create(
58
+ last, (8, 6, 4, 2), "uint16", (16, 16, 45), (4, 4, 0), axes=(*XYZ, "c"), chunks=(4, 3, 2, 1)
59
+ )
60
+ assert wa.open(last, axes_order="zyx").axes == ("c", "z", "y", "x")
61
+
62
+
63
+ def test_a_write_through_the_reordered_view_lands_in_the_stores_order(tmp_path):
64
+ path = str(tmp_path / "w.zarr/s0")
65
+ wa.create(
66
+ path,
67
+ (8, 6, 4),
68
+ "uint16",
69
+ (16, 16, 45),
70
+ (4, 4, 0),
71
+ axes=XYZ,
72
+ chunks=(2, 3, 2),
73
+ shards=(4, 6, 2),
74
+ )
75
+ vol = wa.open(path, mode="r+", axes_order="zyx")
76
+ assert vol.meta.shard_shape == (2, 6, 4) and vol.write_shape == (2, 6, 4)
77
+ zyx = pattern((4, 6, 8))
78
+ vol.write_index((0, 0, 0), (2, 6, 4), zyx[:2, :, :4]) # one shard: z 0-2, x 0-4
79
+ vol.write_chunk((1, 0, 1), zyx[2:, :, 4:])
80
+ stored = wa.open(path)
81
+ np.testing.assert_array_equal(
82
+ stored.read_index((0, 0, 0), (4, 6, 2)), zyx[:2, :, :4].transpose(2, 1, 0)
83
+ )
84
+ np.testing.assert_array_equal(
85
+ stored.read_index((4, 0, 2), (8, 6, 4)), zyx[2:, :, 4:].transpose(2, 1, 0)
86
+ )
87
+ with pytest.raises(ValueError, match="whole write units"):
88
+ vol.write_index((0, 0, 0), (1, 6, 4), zyx[:1, :, :4])
89
+ # keys are the store's; the indices are in the presented order
90
+ assert vol.chunk_key((1, 0, 1)) == stored.chunk_key((1, 0, 1)[::-1]) == "c/1/0/1"
91
+ assert vol.chunk_key((0, 0, 1)) == "c/1/0/0"
92
+ assert vol.parse_chunk_key("c/1/0/0") == (0, 0, 1)
93
+ assert vol.list_chunks() == [(0, 0, 0), (1, 0, 1)]
94
+ assert stored.list_chunks() == [(0, 0, 0), (1, 0, 1)]
95
+ for b in vol.blocks():
96
+ assert vol.parse_chunk_key(vol.chunk_key(b.index)) == b.index
97
+
98
+
99
+ def test_a_selected_axis_keeps_its_stored_place_in_a_key_index(tmp_path):
100
+ path = str(tmp_path / "k.zarr/s0")
101
+ wa.create(
102
+ path, (8, 6, 4, 2), "uint16", (16, 16, 45), (4, 4, 0), axes=(*XYZ, "c"), chunks=(4, 3, 2, 1)
103
+ )
104
+ vol = wa.open(path, axes_order="zyx", select={"c": 1})
105
+ assert vol.axes == ("z", "y", "x")
106
+ # stored (x, y, z, c); the key index is (z, y, x, c): c stays last, where it is stored
107
+ assert vol.chunk_key((1, 0, 1, 1)) == "c/1/0/1/1"
108
+ assert vol.chunk_key((0, 1, 1, 0)) == "c/1/1/0/0"
109
+ assert vol.parse_chunk_key("c/1/1/0/0") == (0, 1, 1, 0)
110
+
111
+
112
+ def test_a_sequence_names_the_order_and_a_bad_order_is_refused(tmp_path):
113
+ path, data = _xyz(tmp_path, chunks=(4, 3, 2))
114
+ vol = wa.open(path, axes_order=("y", "z", "x"))
115
+ assert vol.axes == ("y", "z", "x") and vol.voxel_size == (16, 45, 16)
116
+ np.testing.assert_array_equal(vol.read_index((0, 0, 0), vol.shape), data.transpose(1, 2, 0))
117
+ with pytest.raises(ValueError, match="axes_order is"):
118
+ wa.open(path, axes_order="xyz")
119
+ with pytest.raises(ValueError, match="must name each of the axes"):
120
+ wa.open(path, axes_order=("z", "y"))
121
+ with pytest.raises(ValueError, match="names an axis twice"):
122
+ wa.open(path, axes_order=("z", "z", "x"))
123
+
124
+
125
+ def test_n5_and_unnamed_arrays_are_unchanged_under_zyx(tmp_path):
126
+ data = pattern((4, 6, 8))
127
+ write_n5(tmp_path / "a.n5", data, attrs={"resolution": [16, 16, 45], "axes": ["x", "y", "z"]})
128
+ n5 = wa.open(str(tmp_path / "a.n5"), axes_order="zyx", units=None)
129
+ assert n5.axes == ("z", "y", "x") and n5.voxel_size == (45, 16, 16)
130
+ np.testing.assert_array_equal(n5.read_index((0, 0, 0), n5.shape), data)
131
+ write_zarr2(tmp_path / "u.zarr", data, chunks=(2, 3, 4))
132
+ unnamed = wa.open(str(tmp_path / "u.zarr"), axes_order="zyx", units=None)
133
+ assert unnamed.axes == ("z", "y", "x") and not unnamed.meta.axes_named
134
+ np.testing.assert_array_equal(unnamed.read_index((0, 0, 0), unnamed.shape), data)
135
+
136
+
137
+ def test_metadata_with_and_without_opening(tmp_path):
138
+ path, _ = _xyz(tmp_path, chunks=(4, 3, 2))
139
+ for opened in (True, False):
140
+ meta = wa.metadata(path, axes_order="zyx", open=opened)
141
+ assert meta.axes == ("z", "y", "x") and meta.shape == (4, 6, 8)
142
+ assert meta.chunk_shape == (2, 3, 4) and meta.voxel_size == (45, 16, 16)
143
+ assert meta.translation == (0, 4, 4)
144
+ assert wa.metadata(path, open=False).axes == XYZ
145
+
146
+
147
+ def _xyz_pyramid(tmp_path) -> str:
148
+ """s0 at 16, 16, 45 nm and s1 at 32, 32, 45 (x and y halved), stored x, y, z."""
149
+ _xyz(tmp_path, chunks=(4, 3, 2))
150
+ group = str(tmp_path / "xyz.zarr")
151
+ wa.open_pyramid(group, mode="r+").add_level("s1", factors=(2, 2, 1))
152
+ return group
153
+
154
+
155
+ def test_a_pyramid_presents_its_placements_and_level_meta_in_the_order(tmp_path):
156
+ group = _xyz_pyramid(tmp_path)
157
+ pyr = wa.open_pyramid(group, axes_order="zyx")
158
+ assert pyr.placements() == [
159
+ ("s0", (45.0, 16.0, 16.0), (0.0, 4.0, 4.0)),
160
+ ("s1", (45.0, 32.0, 32.0), (0.0, 12.0, 12.0)),
161
+ ]
162
+ assert pyr.level_meta("s1").shape == (4, 3, 4) and pyr.level_meta("s1").axes == ("z", "y", "x")
163
+ assert pyr.by_name("s1").shape == (4, 3, 4) and pyr.by_name("s1").axes == ("z", "y", "x")
164
+ # a level chosen by an anisotropic voxel size, given in the presented order
165
+ vol = wa.open(group, voxel_size=(45, 32, 32), axes_order="zyx")
166
+ assert vol.level_name == "s1" and vol.voxel_size == (45, 32, 32)
167
+ with pytest.raises(ValueError, match="no level at voxel size"):
168
+ wa.open(group, voxel_size=(45, 32, 32)) # read as x, y, z: no such level
169
+
170
+
171
+ def test_base_voxel_size_lands_on_the_presented_axes(tmp_path):
172
+ group = _xyz_pyramid(tmp_path)
173
+ vol = wa.open(group, level="s1", axes_order="zyx", base_voxel_size=(450, 160, 160))
174
+ assert vol.voxel_size == (450, 320, 320)
175
+
176
+
177
+ def test_add_level_takes_its_arguments_in_the_presented_order(tmp_path):
178
+ group = _xyz_pyramid(tmp_path)
179
+ pyr = wa.open_pyramid(group, mode="r+", axes_order="zyx")
180
+ s2 = pyr.add_level("s2", factors=(2, 2, 2), from_level="s1")
181
+ s3 = pyr.add_level("s3", factors=(1, 2, 2), from_level="s1", chunks=(2, 3, 4))
182
+ assert s2.axes == s3.axes == ("z", "y", "x")
183
+ assert s2.voxel_size == (90, 64, 64) and s3.voxel_size == (45, 64, 64)
184
+ stored = wa.open(f"{group}/s3")
185
+ assert stored.axes == XYZ and stored.voxel_size == (64, 64, 45)
186
+ assert stored.shape == (2, 2, 4) and stored.meta.chunk_shape == (4, 3, 2)
187
+
188
+
189
+ def test_the_order_survives_reopen_and_a_pickle(tmp_path):
190
+ path, data = _xyz(tmp_path, chunks=(4, 3, 2))
191
+ vol = wa.open(path, axes_order="zyx")
192
+ for again in (vol.reopen(), pickle.loads(pickle.dumps(vol))):
193
+ assert again.axes == ("z", "y", "x")
194
+ np.testing.assert_array_equal(
195
+ again.read_index((0, 0, 0), again.shape), data.transpose(2, 1, 0)
196
+ )
197
+ assert vol.reopen(axes_order="stored").axes == XYZ
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.9.1"
1250
+ version = "0.10.0"
1251
1251
  source = { editable = "." }
1252
1252
  dependencies = [
1253
1253
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
File without changes
File without changes
File without changes
File without changes