diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..49b7fe2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,55 @@ +name: Publish to PyPI + +# Runs when you publish a GitHub Release (Releases -> Draft a new release -> Publish), +# OR manually via the "Run workflow" button on the Actions tab (workflow_dispatch). +# Builds the sdist + wheel and uploads them to PyPI via Trusted Publishing (OIDC) - +# no API token/secret required (configure the trusted publisher on PyPI once, see README). +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade build + python -m build + + - name: Check metadata + run: | + python -m pip install --upgrade twine + python -m twine check dist/* + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + # The GitHub environment must match the one set on the PyPI trusted publisher. + environment: + name: pypi + url: https://pypi.org/project/dyna-zarr/ + permissions: + id-token: write # OIDC token for Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 6198dad..ba394b5 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ env/ # Project-specific _archive/ .coverage + +# Non-shipped / local-only (bench + perf report; tests ARE tracked and CI-run) +benchmarks/ +reports/ diff --git a/README.md b/README.md index cd047b5..bd15fa5 100644 --- a/README.md +++ b/README.md @@ -1,172 +1,182 @@ -# dyna_zarr +# dyna-zarr -A lightweight Python library for lazy operations on Zarr arrays. +A lightweight, dask-free Python library for lazy, memory-bounded operations on large Zarr (and TIFF) arrays, with an optional GPU path. ## Overview -**dyna_zarr** provides a thin layer on top of [Zarr](https://zarr-python.readthedocs.io/) for lazy and dynamic array processing. It is designed to simplify working with large, multidimensional datasets by enabling memory-efficient, region-wise I/O and computation. +dyna-zarr is a thin, pull-based array layer over [Zarr](https://zarr-python.readthedocs.io/). Instead of building a task graph, every operation is a lazy *transform* whose `read(key)` maps an output slice back to a bounded input read, ending at a direct zarr/TensorStore read. Slicing a result pulls only that region through the whole operation chain, so no intermediates are materialized. + +The practical consequence is memory-boundedness. When you stream a result to disk with `io.write`, the array is processed region by region, so peak RAM is a function of the region and worker budget rather than the array size. This makes it possible to read, transform, and write arrays far larger than memory. + +## Memory-boundedness + +There are two ways to run a lazy result, with different memory behavior: + +- `io.write(result, path)` streams the result to disk region by region. Peak RAM is roughly `region_size_mb * max_workers`, independent of the array size. This is the memory-bounded path. +- `result.compute()` returns a single in-memory NumPy array. It materializes the whole result by design (mirroring `dask.array.compute`), so it is not memory-bounded. Use it only for results that fit in RAM. + +Every operation is memory-bounded on the `io.write` path except `median`, `argmin`, and `argmax`, which are flagged in the operations catalog below. ## Features -- **Lazy evaluation** – Operations are deferred until explicitly computed -- **Multi-format I/O** – Read from TIFF, Zarr v2, and Zarr v3; write to Zarr v2 or Zarr v3 -- **Efficient region-wise processing** – Data is processed in regions, where each region may span multiple chunks -- **Minimal dependencies** – Requires only `zarr`, `numpy`, `tensorstore`, and `tifffile` +- **Pull-based and lazy.** Operations defer until `.compute()` (materialize) or `io.write` (stream to disk). +- **Memory-bounded streaming.** Region-wise `io.write` with per-worker memory and worker-count knobs. Even reshape, flatten, and rechunk of incompatibly-chunked data stay bounded, by staging through disk. +- **NumPy-like.** Operator overloads, array methods (`.astype`, `.clip`, `.round`), and the NumPy ufunc protocol (`np.sqrt(a)`, `np.add(a, 2)`) all work on a `DynamicArray`. +- **Rich op set.** About 90 operations: pointwise ufuncs, streaming reductions, neighborhood (halo) filters, structural reshaping, differences, and array creation. +- **Multi-format I/O.** Read TIFF, Zarr v2, and Zarr v3 (local, S3/GCS, HTTP); write Zarr v2/v3 with optional sharding. +- **Optional GPU.** Run an op chain on CUDA via CuPy, with a single host-to-device transfer per region. ## Installation ```bash -pip install git+https://github.com/bugraoezdemir/dyna_zarr.git +pip install dyna-zarr ``` -For development: +Optional GPU support (pick the extra matching your CUDA toolkit from `nvidia-smi`): ```bash -git clone https://github.com/bugraoezdemir/dyna_zarr.git -cd dyna_zarr -pip install -e ".[dev]" +pip install "dyna-zarr[gpu-cu12]" # CUDA 12.x ([gpu] is an alias for this) +pip install "dyna-zarr[gpu-cu11]" # CUDA 11.x +pip install "dyna-zarr[gpu-cu13]" # CUDA 13.x (e.g. Blackwell) ``` -## Quick Start +## Quick start -### Reading Arrays +### Read ```python from dyna_zarr import io -# Read a TIFF file as a DynamicArray -arr = io.read("image.tiff") -print(arr.shape) # (100, 256, 256) -print(arr.dtype) # dtype('uint16') - -# Read Zarr v2 -arr = io.read("array_v2.zarr") - -# Read Zarr v3 -arr = io.read("array_v3.zarr") +arr = io.read("image.tiff") # TIFF via tifffile's zarr bridge +arr = io.read("array_v2.zarr") # Zarr v2 +arr = io.read("array_v3.zarr") # Zarr v3 (also s3://, gs://, http://) -# Compute the full array into memory -data = arr.compute() +print(arr.shape, arr.dtype, arr.chunks) -# Compute a region of interest -region = arr[10:20, 50:150, 100:200].compute() +data = arr.compute() # materialize the whole array +region = arr[10:20, 50:150, 100:200].compute() # pull just this region ``` -### Writing Arrays +### Write (memory-bounded streaming) ```python from dyna_zarr import io, Codecs -# Write to Zarr v3 -io.write(arr, "output_v3.zarr", zarr_format=3) +io.write(arr, "out_v3.zarr", zarr_format=3) +io.write(arr, "out.zarr", chunks=(64, 64, 64), zarr_format=3) +io.write(arr, "out.zarr", dtype="float32", zarr_format=3) # cast on write +io.write(arr, "out.zarr", compressor=Codecs(compressor="zstd", clevel=5), zarr_format=3) -# Write to Zarr v2 -io.write(arr, "output_v2.zarr", zarr_format=2) - -# Specify custom chunks -io.write(arr, "output.zarr", chunks=(64, 64, 64), zarr_format=3) - -# Enable compression -codecs = Codecs(compressor="zstd", clevel=5) -io.write(arr, "output.zarr", compressor=codecs, zarr_format=3) - -# Convert dtype during write -io.write(arr, "output.zarr", dtype="float32", zarr_format=3) +# memory and parallelism controls (peak RAM is roughly region_size_mb * max_workers) +io.write(arr, "out.zarr", region_size_mb=64, max_workers=4) ``` -### Lazy Operations +### Lazy operation chains ```python -from dyna_zarr import io, operations +from dyna_zarr import io, operations as ops -# Read array arr = io.read("input.zarr") -# Apply lazy transformations (no computation yet) -result = operations.abs(arr) -result = operations.clip(result, 0, 1) -result = operations.sqrt(result) +result = ops.sqrt(ops.clip(ops.abs(arr), 0, 1)) # nothing computed yet +io.write(result, "output.zarr", zarr_format=3) # streamed, region by region +# ...or result.compute() to materialize +``` -# Write result using region-wise processing -io.write(result, "output.zarr", zarr_format=3) +### NumPy-like interface + +A `DynamicArray` behaves like a NumPy or dask array. Operators, methods, and ufuncs are all lazy: + +```python +import numpy as np -# Or fully materialize the result -final_data = result.compute() +masked = (arr > 3) & (arr < 100) # elementwise operators build a lazy mask +scaled = (arr.astype("float32") / 255).clip(0, 1) +out = np.sqrt(np.abs(arr)) # NumPy ufunc protocol dispatches to lazy ops ``` -### Multi-source Operations +### Neighborhood filters + +Neighborhood (halo) filters wrap `scipy.ndimage`. Each read pulls its own halo, so results are chunk-invariant and exact, and stay memory-bounded when streamed. ```python -from dyna_zarr import io, operations +import numpy as np +from dyna_zarr import io, operations as ops + +img = io.read("volume.zarr") # e.g. (z, y, x) -# Read multiple sources -arr1 = io.read("input1.zarr") -arr2 = io.read("input2.zarr") +# LoG filtering +log = ops.gaussian_laplace(img, sigma=2) +io.write(log, "log.zarr", zarr_format=3) # halo handled per region -# Chain operations -result = operations.concatenate([arr1, arr2], axis=0) -result = operations.clip(result, -1, 1) +# median denoise +denoised = ops.median_filter(img, size=3) +io.write(denoised, "denoised.zarr") -# Write result -io.write(result, "concatenated_output.zarr", zarr_format=3) +# a custom per-plane kernel +kernel = np.ones((1, 3, 3), dtype="float32") / 9 # 3x3 mean within each z-plane +blurred = ops.convolve(img, kernel) +io.write(blurred, "blurred.zarr") ``` -## Core Components +## Operations catalog -- **`io.read()`** – Read TIFF, Zarr v2, or Zarr v3 sources and return a - `DynamicArray` -- **`io.write()`** – Write a `DynamicArray` to Zarr v2 or v3 with optional - region-wise execution -- **`operations`** – Lazy transformation functions such as `abs`, - `clip`, `sqrt`, `concatenate`, `reshape`, and `slice` -- **`DynamicArray`** – Core lazy array abstraction supporting slicing, - shape/dtype inspection, and `.compute()` -- **`Codecs`** – Compression configuration for Zarr v2 and v3 +Every operation is lazy, and memory-bounded on the `io.write` path except `median`, `argmin`, and `argmax` (see Memory-boundedness). All are available flat on `dyna_zarr.operations`, and also grouped by category submodule. -## Further Examples +- **Pointwise / ufuncs.** `abs`, `negative`, `sign`, `sqrt`, `square`, `exp`, `log`, `log2`, `log10`, `floor`, `ceil`, `reciprocal`, `round`, `clip`, `astype`; binary `add`, `subtract`, `multiply`, `divide`, `floor_divide`, `mod`, `power`, `maximum`, `minimum`; comparisons `greater(_equal)`, `less(_equal)`, `equal`, `not_equal`; logical `and`, `or`, `xor`, `not`; `where`, `isin`, `digitize`. +- **Reductions.** Streaming and memory-bounded: `min`, `max`, `sum`, `prod`, `mean`, `any`, `all`, `var`, `std`, `histogram` (with `axis=` and `keepdims=`). Not fully bounded (hold the full reduced axis): `median`, `argmin`, `argmax`. +- **Neighborhood (halo/overlap).** `gaussian_filter`, `uniform_filter`, `median_filter`, `minimum_filter`, `maximum_filter`, `grey_erosion`, `grey_dilation`, `convolve`, `correlate`, `laplace`, `gaussian_laplace`, `gaussian_gradient_magnitude`. +- **Structural.** `concatenate`, `stack`, `transpose`, `swap_axes`, `reshape`, `flatten`, `squeeze`, `expand_dims`, `pad`, `tile`, `roll`, `flip`, `rot90`, `slice_array`. +- **Differences.** `diff`, `gradient`. +- **Scan (prefix, along one axis).** `cumsum`, `cumprod`, `cummax`, `cummin`. Streamed with a bounded carry on the `io.write` path, so memory-bounded despite the sequential dependency. +- **Creation.** `zeros`, `ones`, `full`, `empty`, `random` (and the `*_like` variants). `random` is position-deterministic, so the result is independent of chunking. +- **Primitives.** `map_blocks` (pointwise), `map_overlap` (neighborhood with a halo), `reduce` (streaming). Use these to build your own ops. -### Manual Configuration of Region Size +## Memory-bounded reshape, flatten, and rechunk -```python -from dyna_zarr import io +C-order reshape and flatten conflict with n-dimensional chunk layout, so a naive implementation blows up. dyna-zarr stages these through disk (a Rechunker-style two-phase, read-once/write-once copy), so peak RAM stays a function of the per-worker budget rather than the array size. When you `io.write` an outermost `reshape` or `flatten`, this path is used automatically: -arr = io.read("large_array.zarr") +```python +from dyna_zarr import io, operations as ops -# Process approximately 64 MB regions at a time -io.write(arr, "output.zarr", region_size_mb=64) +arr = io.read("big_4d.zarr") # e.g. 5 GB, awkward chunks +io.write(ops.flatten(arr), "flat.zarr", region_size_mb=128, max_workers=2) +io.write(ops.reshape(arr, (a, b)), "reshaped.zarr") # (a, b) is any target shape of the same size ``` -### Zarr Sharding (Zarr v3 only) +## GPU (optional) + +With a CuPy install, run a chain on the GPU. Setting `device='cuda'` on a terminal call (`compute` or `io.write`) makes device-inheriting ops run on the GPU. A single host-to-device transfer happens at the first CUDA op and the data stays resident up the chain. Results are returned or written from the host. ```python -io.write( - arr, - "sharded_output.zarr", - chunks=(64, 64, 64), # Inner chunk size - shard_coefficients=(4, 4, 4), # Shard size = 4x chunks in each dimension - zarr_format=3, -) +result = ops.gaussian_filter(arr, sigma=3) +out = result.compute(device="cuda") # whole chain on the GPU +io.write(result, "out.zarr", device="cuda") # per-region GPU compute, streamed write ``` -## Requirements +## Relationship to dask -- Python >= 3.11 -- zarr >= 3.0.0 -- numpy >= 1.20.0 -- tensorstore -- tifffile +dyna-zarr is not a general replacement for `dask.array`. It targets one job: memory-bounded read, transform, and write of large Zarr/TIFF arrays. -## Testing +The core idea is to drop the task graph. Because every operation is a pull-based chain, where each output slice maps back to a bounded input read, there is no graph to build and no scheduler to run it. That keeps the engine small, keeps peak RAM bounded by `region_size_mb * max_workers` on the `io.write` path, and avoids scheduling overhead, which makes the read-transform-write pipeline efficient. -Run the test suite: +The tradeoff is that only operations that fit this slice-pushdown model belong in the chain: pointwise math, neighborhood/halo filters, streaming reductions, and structural reshaping. These are operations that are commonly used in image processing, which is what dyna-zarr is mainly built for. Operations that would need a global, data-dependent graph do not fit directly, and a few that do (such as non-associative reductions) trade extra reads or memory to stay correct. -```bash -pytest tests/ -v -``` +Two more differences worth knowing: -Run tests with coverage: +- **Single machine, for now.** Parallelism today is threaded I/O within one process, plus the optional GPU path. There is no cluster or distributed execution yet; better and process-based parallelism is a possible future direction. +- **Narrower surface.** About 90 operations today, extended where the slice-pushdown model permits. Binary ops also need equal-shaped operands (no general broadcasting between differently shaped lazy arrays yet). -```bash -pytest tests/ --cov=src/dyna_zarr --cov-report=html -``` +## Core components + +- `io.read(source)` reads TIFF, Zarr v2, or Zarr v3 (local or remote) into a `DynamicArray`. +- `io.write(array, path, ...)` streams a `DynamicArray` to Zarr v2/v3 (chunks, sharding, compression, dtype cast, `region_size_mb`, `max_workers`, `device`). +- `operations` is the lazy op set above. +- `DynamicArray` is the pull-based lazy array (slicing, `.compute()`, operators, `.astype`/`.clip`/`.round`, ufunc protocol). +- `Codecs` is the compression configuration for Zarr v2 and v3. + +## Requirements +- Python 3.11 or newer +- zarr 3.0.0+, numpy 1.20+, scipy 1.6+, tensorstore, tifffile +- Optional: CuPy (via the `gpu-cuXX` extras) for the GPU path diff --git a/pyproject.toml b/pyproject.toml index 4336fd0..51a8dc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ packages = ["dyna_zarr"] "" = "src" [project] -name = "dyna_zarr" -version = "0.0.1" +name = "dyna-zarr" +version = "0.0.2" description = "A lightweight library for lazy operations on Zarr arrays without task graph overhead" readme = "README.md" requires-python = ">=3.11" @@ -32,6 +32,7 @@ classifiers = [ dependencies = [ "zarr>=3.0.0", "numpy>=1.20.0", + "scipy>=1.6.0", "tensorstore", "tifffile" ] @@ -49,6 +50,18 @@ docs = [ "sphinx>=5.0", "sphinx-rtd-theme>=1.0", ] +# GPU execution (optional). CuPy ships CUDA-version-specific wheels and pip CANNOT +# auto-detect your CUDA, so pick the extra that matches `nvidia-smi` (CUDA Version): +# pip install dyna-zarr[gpu-cu11] # CUDA 11.x +# pip install dyna-zarr[gpu-cu12] # CUDA 12.x +# pip install dyna-zarr[gpu-cu13] # CUDA 13.x (e.g. Blackwell) +# `[gpu]` is a convenience alias for CUDA 12.x. The [ctk] extra ships the NVRTC headers +# CuPy needs to JIT kernels (without it every kernel raises "Failed to find CUDA headers"). +# Auto-detect alternative (may lag new CUDA releases): pip install cupy-wheel +gpu = ["cupy-cuda12x[ctk]"] +gpu-cu11 = ["cupy-cuda11x[ctk]"] +gpu-cu12 = ["cupy-cuda12x[ctk]"] +gpu-cu13 = ["cupy-cuda13x[ctk]"] [project.urls] Homepage = "https://github.com/bugraoezdemir/dyna_zarr" diff --git a/scripts/clean_extracted.py b/scripts/clean_extracted.py deleted file mode 100644 index 24a348b..0000000 --- a/scripts/clean_extracted.py +++ /dev/null @@ -1,20 +0,0 @@ -from pathlib import Path -p = Path('htmlcov_extracted_dynamic_array_fixed.py') -text = p.read_text() -lines = text.splitlines() -# find first line that starts with 'import zarr' -start = 0 -for i,l in enumerate(lines): - if l.strip().startswith('import zarr'): - start = i - break -# find last occurrence of the write return -end = 0 -for i in range(len(lines)-1, -1, -1): - if 'return result._with_transform(transform)' in lines[i] or 'return array._with_transform(transform)' in lines[i]: - end = i - break -clean = '\n'.join(lines[start:end+1]) -out = Path('htmlcov_extracted_dynamic_array_clean.py') -out.write_text(clean) -print('wrote', out) diff --git a/scripts/extract_dynamic_array.py b/scripts/extract_dynamic_array.py deleted file mode 100644 index 7f9620f..0000000 --- a/scripts/extract_dynamic_array.py +++ /dev/null @@ -1,22 +0,0 @@ -import re -import html -from pathlib import Path -html_path = Path('htmlcov/z_5bb88312f9bf08c8_dynamic_array_py.html') -out_path = Path('htmlcov_extracted_dynamic_array.py') -text = html_path.read_text() -# extract all

...

-parts = re.findall(r']*>(.*?)

', text, flags=re.S) -lines = [] -for p in parts: - # remove tags - s = re.sub(r'<[^>]+>', '', p) - s = html.unescape(s) - # remove leading line numbers if present - s = re.sub(r'^\s*\d+\s*', '', s) - # strip trailing non-breaking spaces - s = s.replace('\xa0', ' ') - lines.append(s.rstrip()) -# join and write -out = '\n'.join(lines) -out_path.write_text(out) -print('wrote', out_path) diff --git a/scripts/extract_dynamic_array_fix.py b/scripts/extract_dynamic_array_fix.py deleted file mode 100644 index 9027a4f..0000000 --- a/scripts/extract_dynamic_array_fix.py +++ /dev/null @@ -1,18 +0,0 @@ -import re -import html -from pathlib import Path -html_path = Path('htmlcov/z_5bb88312f9bf08c8_dynamic_array_py.html') -out_path = Path('htmlcov_extracted_dynamic_array_fixed.py') -text = html_path.read_text() -parts = re.findall(r']*>(.*?)

', text, flags=re.S) -lines = [] -for p in parts: - s = re.sub(r'<[^>]+>', '', p) - s = html.unescape(s) - # remove only the leading line number, keep following spaces - s = re.sub(r'^\s*\d+', '', s) - s = s.replace('\xa0', ' ') - lines.append(s.rstrip()) -out = '\n'.join(lines) -out_path.write_text(out) -print('wrote', out_path) diff --git a/scripts/extract_grid_ok.py b/scripts/extract_grid_ok.py deleted file mode 100644 index 1aba309..0000000 --- a/scripts/extract_grid_ok.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -import json -import csv -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -JSON_PATH = ROOT / 'grid_search_results.json' -OUT_CSV = ROOT / 'grid_search_ok_sorted.csv' - -with JSON_PATH.open() as f: - data = json.load(f) - -ok_entries = [] -config_keys = set() -for i, entry in enumerate(data): - res = entry.get('result', {}) - if res.get('status') == 'ok': - cfg = entry.get('config', {}) - config_keys.update(cfg.keys()) - row = { - 'index': i, - 'elapsed': res.get('elapsed'), - 'trial_time': entry.get('trial_time') - } - # flatten config - for k, v in cfg.items(): - row[f'cfg_{k}'] = v - # include any other result fields except status/elapsed - for k, v in res.items(): - if k in ('status','elapsed'): - continue - row[f'result_{k}'] = v - ok_entries.append(row) - -# sort by elapsed (None goes to end) -ok_entries.sort(key=lambda r: (r['elapsed'] is None, r['elapsed'])) - -# build header -cfg_keys_sorted = sorted(list(config_keys)) -headers = ['index','elapsed','trial_time'] + [f'cfg_{k}' for k in cfg_keys_sorted] -# collect extra result keys -extra_result_keys = set() -for r in ok_entries: - for k in r.keys(): - if k.startswith('result_'): - extra_result_keys.add(k) -headers += sorted(extra_result_keys) - -with OUT_CSV.open('w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=headers) - writer.writeheader() - for r in ok_entries: - # ensure all header keys exist - out = {h: r.get(h, '') for h in headers} - writer.writerow(out) - -print(f'Wrote {len(ok_entries)} successful trials to {OUT_CSV}') diff --git a/scripts/extract_grid_search_results.py b/scripts/extract_grid_search_results.py deleted file mode 100644 index cc65488..0000000 --- a/scripts/extract_grid_search_results.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -"""Extract successful trials from grid_search_results.json and write CSV. - -Usage: python3 scripts/extract_grid_search_results.py -Creates: grid_search_ok_sorted.csv in repository root. -""" -import json -import csv -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -SRC = ROOT / "grid_search_results.json" -OUT = ROOT / "grid_search_ok_sorted.csv" - - -def main(): - data = json.loads(SRC.read_text()) - - # collect all config keys to build CSV header - config_keys = set() - rows = [] - for entry in data: - result = entry.get("result", {}) - if result.get("status") != "ok": - continue - config = entry.get("config", {}) - config_keys.update(config.keys()) - elapsed = result.get("elapsed") - trial_time = entry.get("trial_time") - rows.append({"elapsed": elapsed, "trial_time": trial_time, **config}) - - if not rows: - print("No successful trials found.") - return - - # order config columns consistently - config_keys = sorted(config_keys) - - # sort rows by elapsed (ascending) - rows.sort(key=lambda r: float(r.get("elapsed", float("inf")))) - - # write CSV header - header = ["elapsed", "trial_time"] + config_keys - - with OUT.open("w", newline="") as f: - w = csv.DictWriter(f, fieldnames=header) - w.writeheader() - for r in rows: - # ensure all keys present - row = {k: r.get(k, "") for k in header} - w.writerow(row) - - print(f"Wrote {len(rows)} successful trials to {OUT}") - - -if __name__ == "__main__": - main() diff --git a/src/dyna_zarr/__init__.py b/src/dyna_zarr/__init__.py index 4fc6ee0..20abac0 100644 --- a/src/dyna_zarr/__init__.py +++ b/src/dyna_zarr/__init__.py @@ -8,21 +8,21 @@ Includes efficient TIFF reading via tifffile's zarr bridge with concurrent access support. """ -__version__ = "0.0.1" +__version__ = "0.0.2" __author__ = "EuBI-Biohub" # Import core classes from .dynamic_array import DynamicArray, slice_array from .codecs import Codecs -# Import operations and io namespaces -from .operations import operations +# Import operations (now a package/module of flat ops) and io namespaces +from . import operations from .io import io # Optional: expose tifffile utilities if available try: from .io import read_file - from .tiff_reader import read_tiff_lazy, TiffZarrReader + from .tiff_reader import read_tiff_lazy, open_tiff_zarr __all__ = [ "DynamicArray", "operations", @@ -31,7 +31,7 @@ "Codecs", "read_file", "read_tiff_lazy", - "TiffZarrReader", + "open_tiff_zarr", ] except ImportError: __all__ = [ diff --git a/src/dyna_zarr/codecs.py b/src/dyna_zarr/codecs.py index 76262bd..1d40219 100644 --- a/src/dyna_zarr/codecs.py +++ b/src/dyna_zarr/codecs.py @@ -108,54 +108,54 @@ def to_numcodecs(self): def to_v3_config(self): """ - Generate codec pipeline for Zarr v3. + Generate codec pipeline for Zarr v3 using proper zarr.codecs API. Returns: - List of codec configurations + List of codec configurations (from .to_dict() calls) """ - codecs = [ - {'name': 'bytes', 'configuration': {'endian': 'little'}} + from zarr import codecs + + codecs_list = [ + codecs.BytesCodec(endian=codecs.Endian.little).to_dict() ] if self.compressor == 'blosc': - # Convert shuffle integer to string for TensorStore - shuffle_map = {0: 'noshuffle', 1: 'shuffle', 2: 'bitshuffle'} - shuffle_str = shuffle_map.get(self.shuffle, 'shuffle') + # Use BloscCodec with proper BloscShuffle enum + shuffle_map = {0: codecs.BloscShuffle.noshuffle, + 1: codecs.BloscShuffle.shuffle, + 2: codecs.BloscShuffle.bitshuffle} + shuffle_enum = shuffle_map.get(self.shuffle, codecs.BloscShuffle.shuffle) + + blosc_codec = codecs.BloscCodec( + cname=self.cname, + clevel=self.clevel, + shuffle=shuffle_enum + ) + codecs_list.append(blosc_codec.to_dict()) - codecs.append({ - 'name': 'blosc', - 'configuration': { - 'cname': self.cname, - 'clevel': self.clevel, - 'shuffle': shuffle_str - } - }) elif self.compressor == 'zstd': - codecs.append({ - 'name': 'zstd', - 'configuration': {'level': self.clevel} - }) + zstd_codec = codecs.ZstdCodec(level=self.clevel) + codecs_list.append(zstd_codec.to_dict()) + elif self.compressor == 'gzip': - codecs.append({ - 'name': 'gzip', - 'configuration': {'level': self.clevel} - }) + gzip_codec = codecs.GzipCodec(level=self.clevel) + codecs_list.append(gzip_codec.to_dict()) + elif self.compressor == 'lz4': - codecs.append({ - 'name': 'lz4', - 'configuration': {} - }) + # LZ4 is not directly available in zarr.codecs v3, would need blosc + raise ValueError("LZ4 is not available in Zarr v3. Use Blosc with cname='lz4' instead.") + elif self.compressor == 'bz2': - codecs.append({ - 'name': 'bz2', - 'configuration': {'level': self.clevel} - }) + # BZ2 is not directly available in zarr.codecs v3 + raise ValueError("BZ2 is not available in Zarr v3. Use Zstd or Gzip instead.") + elif self.compressor is None or self.compressor == 'none': - pass # No compression codec + pass # No compression codec, only bytes codec + else: raise ValueError(f"Unsupported compressor: {self.compressor}") - return codecs + return codecs_list @classmethod def from_numcodecs(cls, compressor): diff --git a/src/dyna_zarr/dynamic_array.py b/src/dyna_zarr/dynamic_array.py index 5e73ac4..62aaa7a 100755 --- a/src/dyna_zarr/dynamic_array.py +++ b/src/dyna_zarr/dynamic_array.py @@ -261,21 +261,112 @@ def __getitem__(self, key): transform = SliceTransform(self, key) return self._with_transform(transform) - def compute(self): + def compute(self, device=None): """ Execute all lazy transforms and return the result as a numpy array. + + ``device`` sets the EXECUTION device for inherit-ops (device=None) in the chain: + None/'cpu' runs on the CPU (default), 'cuda' runs the pipeline on the GPU. The + result is always returned as a host numpy array. """ - if self._transform is None: - # No transform - read entire array - if self._is_tensorstore: - return self._ts_array[:].read().result() + from .operations._backend import device_context, to_device + with device_context(device): + if self._transform is None: + # No transform - read entire array + if self._is_tensorstore: + result = self._ts_array[:].read().result() + else: + result = self._zarr_array[:] else: - return self._zarr_array[:] - else: - # Apply transformation to read all data - full_slice = tuple(slice(None) for _ in range(len(self.shape))) - return self._transform.read(full_slice) - + # Apply transformation to read all data + full_slice = tuple(slice(None) for _ in range(len(self.shape))) + result = self._transform.read(full_slice) + return to_device(result, "cpu") + + # --- numpy/dask-like method surface (lazy; route to operations) --- + def astype(self, dtype): + """Lazily cast to ``dtype`` (like ``numpy``/``dask`` ``a.astype``).""" + from . import operations as o + return o.astype(self, dtype) + + def clip(self, a_min=None, a_max=None, out=None): + """Lazily clip to ``[a_min, a_max]`` (either bound may be None). ``out`` accepted for + NumPy-method compatibility (``np.clip`` calls ``a.clip(min, max, out=...)``); a non-None + ``out`` isn't supported on a lazy array.""" + if out is not None: + raise TypeError("clip(out=...) is not supported on a lazy DynamicArray") + from . import operations as o + return o.clip(self, a_min, a_max) + + def round(self, decimals=0, out=None): + """Lazily round (like ``numpy``/``dask`` ``a.round``). ``out`` accepted for NumPy-method + compatibility; a non-None ``out`` isn't supported on a lazy array.""" + if out is not None: + raise TypeError("round(out=...) is not supported on a lazy DynamicArray") + from . import operations as o + return o.round(self, decimals) + + def rechunk(self, chunks=None, **kwargs): + """No-op for the pull model (accepts dask's signature for backend compatibility). + + In dask, ``rechunk`` changes the chunk grid so cross-chunk ops behave. A DynamicArray + is chunk-invariant: every lazy read pulls exactly the region asked for, independent of + any chunk grid, and streaming reductions/scans already see whole axes. So a lazy + rechunk changes nothing about correctness and returns the array unchanged. Storage + chunking is a separate concern, set via ``io.write(chunks=...)`` or the rechunk engine. + """ + return self + + def persist(self, **kwargs): + """No-op (accepts dask's signature). dask ``persist`` materializes and caches an + intermediate; the pull model has no graph to cache, so this returns the array + unchanged. Use ``io.write`` to stage an intermediate to disk when needed.""" + return self + + def map_blocks(self, func, *args, dtype=None, **kwargs): + """dask-compatible ``map_blocks``: apply ``func`` blockwise (shape-preserving). Extra + array/scalar ``args`` become additional equally-shaped operands; dask-only kwargs + (``meta``/``chunks``/``name``/``block_info``/...) are ignored, and any remaining kwargs + are bound to ``func``. ``drop_axis``/``new_axis`` (shape-changing) are not supported.""" + from . import operations as o + if kwargs.get("drop_axis") is not None or kwargs.get("new_axis") is not None: + raise NotImplementedError( + "map_blocks drop_axis/new_axis is not supported (shape-preserving only)") + for k in ("meta", "chunks", "name", "token", "drop_axis", "new_axis", + "block_info", "block_id", "enforce_ndim"): + kwargs.pop(k, None) + f = (lambda *bs, _f=func, _kw=kwargs: _f(*bs, **_kw)) if kwargs else func + return o.map_blocks(f, self, *args, dtype=dtype) + + def map_overlap(self, func, depth=0, boundary="reflect", trim=True, dtype=None, **kwargs): + """dask-compatible ``map_overlap``: apply a shape-preserving neighbourhood ``func`` + with a ``depth`` halo. dask-only kwargs are ignored and any remaining kwargs are bound + to ``func``. ``trim=False`` (shrinking output) is not supported.""" + from . import operations as o + if not trim: + raise NotImplementedError("map_overlap trim=False is not supported") + for k in ("meta", "chunks", "name"): + kwargs.pop(k, None) + f = (lambda b, _f=func, _kw=kwargs: _f(b, **_kw)) if kwargs else func + return o.map_overlap(self, f, depth, boundary=boundary, dtype=dtype) + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + """NumPy ufunc protocol -> lazy ops, so ``np.sqrt(a)`` / ``np.add(a, 2)`` work on a + DynamicArray exactly as on a dask array. Only the plain ``__call__`` form (no ``out=``, + no reductions) is handled; anything else -- or a ufunc dyna_zarr doesn't implement -- + returns ``NotImplemented`` so NumPy can fall back / raise clearly.""" + from . import operations as o + if method != "__call__" or kwargs.get("out") is not None: + return NotImplemented + name = _UFUNC_ALIASES.get(ufunc.__name__, ufunc.__name__) + fn = getattr(o, name, None) + if fn is None: + return NotImplemented + try: + return fn(*inputs) + except (TypeError, ValueError): + return NotImplemented + def _read_direct(self, key): """ Internal method to read data directly without creating transforms. @@ -304,46 +395,127 @@ def _with_transform(self, transform): result._dtype = transform.dtype # Keep zarr metadata from original return result + + @classmethod + def _from_transform(cls, transform): + """Build a SOURCELESS DynamicArray whose data is synthesized by ``transform`` (a + generative source, e.g. the creation ops). There is no underlying zarr/tensorstore + array; every read goes through ``transform.read(key)``.""" + self = cls.__new__(cls) + self._source = None + self._zarr_array = None + self._ts_array = None + self._is_tensorstore = False + self._shape = tuple(transform.shape) + self._chunks = transform.chunks + self._dtype = np.dtype(transform.dtype) + self._transform = transform + self._zarr_format = None + self._compressor = None + self._compressors = None + self._shards = None + self._codecs = None + return self - def min(self, axis: Optional[int] = None): - """Compute minimum along specified axis. - - Parameters - ---------- - axis : int, optional - Axis along which to compute minimum. If None, reduces to scalar. - - Returns - ------- - scalar or numpy.ndarray - Minimum value(s). Computed immediately. - """ + # Eager reduction shortcuts. Each streams via operations. (memory-bound) + # and computes immediately, returning a NumPy scalar/array. Use operations. + # directly (e.g. operations.max(a, 0)) for a lazy, chainable/writable reduction. + def min(self, axis=None, keepdims=False): + """Minimum along ``axis`` (None = all). Computed immediately.""" from . import operations - result = operations.min(self, axis=axis) - return result.compute() - - def max(self, axis: Optional[int] = None): - """Compute maximum along specified axis. - - Parameters - ---------- - axis : int, optional - Axis along which to compute maximum. If None, reduces to scalar. - - Returns - ------- - scalar or numpy.ndarray - Maximum value(s). Computed immediately. - """ + return operations.min(self, axis=axis, keepdims=keepdims).compute() + + def max(self, axis=None, keepdims=False): + """Maximum along ``axis`` (None = all). Computed immediately.""" + from . import operations + return operations.max(self, axis=axis, keepdims=keepdims).compute() + + def sum(self, axis=None, keepdims=False): + """Sum along ``axis`` (None = all). Computed immediately.""" + from . import operations + return operations.sum(self, axis=axis, keepdims=keepdims).compute() + + def mean(self, axis=None, keepdims=False): + """Mean along ``axis`` (None = all). Computed immediately.""" + from . import operations + return operations.mean(self, axis=axis, keepdims=keepdims).compute() + + def prod(self, axis=None, keepdims=False): + """Product along ``axis`` (None = all). Computed immediately.""" + from . import operations + return operations.prod(self, axis=axis, keepdims=keepdims).compute() + + def median(self, axis=None, keepdims=False): + """Median along ``axis`` (None = all). Computed immediately.""" from . import operations - result = operations.max(self, axis=axis) - return result.compute() + return operations.median(self, axis=axis, keepdims=keepdims).compute() + + def histogram(self, bins=256, range=None): + """Streaming histogram over the whole array. Returns ``(counts, bin_edges)`` like + numpy. Slice first for a per-channel/plane histogram: ``da[channel].histogram()``.""" + from . import operations + return operations.histogram(self, bins=bins, range=range) # Import Transform subclasses from .operations import SliceTransform +# --------------------------------------------------------------------------- # +# Operator overloads -> lazy elementwise ops via operations.map_blocks. +# Mirrors numpy / ome_zarr_pyramid.Pyramid: arithmetic and comparisons are +# elementwise; &,|,^,~ are LOGICAL (for boolean masks), matching `~mask`. `a == b` +# returns a lazy mask, but assigning __eq__ *after* the class body leaves the +# inherited identity __hash__ intact, so a DynamicArray is still hashable. +# --------------------------------------------------------------------------- # + +# numpy ufunc names that differ from the operations spelling (see __array_ufunc__). +_UFUNC_ALIASES = { + "absolute": "abs", + "true_divide": "divide", + "bitwise_and": "logical_and", + "bitwise_or": "logical_or", + "bitwise_xor": "logical_xor", + "invert": "logical_not", +} + + +def _binop(opname, reflected=False): + def method(self, other): + from . import operations as o + fn = getattr(o, opname) + return fn(other, self) if reflected else fn(self, other) + method.__name__ = ("__r" if reflected else "__") + opname + "__" + return method + + +def _unop(opname): + def method(self): + from . import operations as o + return getattr(o, opname)(self) + method.__name__ = "__" + opname + "__" + return method + + +for _dunder, _op in { + "add": "add", "sub": "subtract", "mul": "multiply", "truediv": "divide", + "floordiv": "floor_divide", "mod": "mod", "pow": "power", + "and": "logical_and", "or": "logical_or", "xor": "logical_xor", +}.items(): + setattr(DynamicArray, f"__{_dunder}__", _binop(_op)) + setattr(DynamicArray, f"__r{_dunder}__", _binop(_op, reflected=True)) + +for _dunder, _op in { + "lt": "less", "le": "less_equal", "gt": "greater", "ge": "greater_equal", + "eq": "equal", "ne": "not_equal", +}.items(): + setattr(DynamicArray, f"__{_dunder}__", _binop(_op)) + +DynamicArray.__neg__ = _unop("negative") +DynamicArray.__abs__ = _unop("abs") +DynamicArray.__invert__ = _unop("logical_not") + + def slice_array(array: DynamicArray, key) -> DynamicArray: """ Create a lazy slice of an array. diff --git a/src/dyna_zarr/io.py b/src/dyna_zarr/io.py index e202d46..ca194ac 100644 --- a/src/dyna_zarr/io.py +++ b/src/dyna_zarr/io.py @@ -19,10 +19,15 @@ import gc from queue import Queue -from .tiff_reader import read_tiff_lazy +from .tiff_reader import open_tiff_zarr, read_tiff_lazy from .codecs import Codecs from .dynamic_array import DynamicArray from .utils import parse_dtype +from .operations._backend import ( + device_context as _device_context, asnumpy as _asnumpy, + asnumpy_pinned as _asnumpy_pinned, + new_stream as _new_stream, use_stream as _use_stream, +) def _parse_storage_location(file_path): @@ -36,7 +41,12 @@ def _parse_storage_location(file_path): """ if not isinstance(file_path, str): file_path = str(file_path) - + + # Windows drive-letter path (e.g. C:\foo or C:/foo): urlparse would mistake + # the drive letter for a single-character URL scheme, so short-circuit to local. + if len(file_path) >= 2 and file_path[0].isalpha() and file_path[1] == ':': + return 'local', Path(file_path) + # Parse URL scheme parsed = urlparse(file_path) @@ -147,9 +157,9 @@ def read_file(file_path): ) elif _is_tiff_path(str(parsed_path)): - # Local TIFF file - return read_tiff_lazy(parsed_path) - + # Local TIFF file -> lazy zarr array (raw backend object; read_array wraps it) + return open_tiff_zarr(parsed_path) + else: raise ValueError( f"Unsupported file type: {parsed_path.suffix}. " @@ -162,10 +172,8 @@ def read_file(file_path): path_str = str(parsed_path) if _is_tiff_path(path_str): - # TIFF file on cloud storage - # Note: tifffile may support remote TIFF via fsspec - # For now, try to read via tifffile (it should handle the URL) - return read_tiff_lazy(file_path) + # TIFF file on cloud storage (tifffile may support remote via fsspec) + return open_tiff_zarr(file_path) else: # Assume it's a Zarr array on cloud storage @@ -234,7 +242,7 @@ def read_file(file_path): if _is_tiff_path(path_str): # TIFF over HTTP - pass to tifffile (may support via fsspec) - return read_tiff_lazy(file_path) + return open_tiff_zarr(file_path) else: # Zarr over HTTP spec = { @@ -276,26 +284,10 @@ def read_array(source: Union[str, Path]) -> 'DynamicArray': source_path = Path(source) if not isinstance(source, Path) else source - # Check if it's a TIFF or zarr file - use read_file for both + # TIFF -> lazy zarr array via tifffile's bridge, wrapped as a normal zarr-backed + # DynamicArray (laziness/slicing/memory-bounded reads all come from DynamicArray). if str(source_path).lower().endswith(('.tif', '.tiff')): - # TIFF file - ts_array = read_file(source) - dyn_array = object.__new__(DynamicArray) - dyn_array._ts_array = ts_array - dyn_array._is_tensorstore = True - dyn_array._source = source - dyn_array._shape = tuple(ts_array.shape) - dyn_array._dtype = ts_array.dtype - dyn_array._transform = None - dyn_array._chunks = ts_array.chunks if hasattr(ts_array, 'chunks') else None - # TIFF files don't have zarr metadata, set defaults - dyn_array._zarr_array = None - dyn_array._zarr_format = None - dyn_array._compressor = None - dyn_array._compressors = None - dyn_array._shards = None - dyn_array._codecs = None - return dyn_array + return read_tiff_lazy(source) elif (isinstance(source_path, Path) and source_path.is_dir() and ((source_path / ".zarray").exists() or @@ -480,6 +472,8 @@ def write_array( gc_interval: float = 15.0, early_quarter_timeout: Optional[float] = None, early_tenth_timeout: Optional[float] = None, + max_inflight_writes: Optional[int] = None, + device: Optional[str] = None, **kwargs ): """ @@ -518,9 +512,44 @@ def write_array( gc_interval : float Seconds between GC runs (default: 15.0) """ + # A reshape/flatten as the OUTERMOST op is a C-order flat re-index that conflicts with + # nd chunk layout -- the region writer can't do it memory-bound. Route it to the + # streaming reindex writer (per-output-chunk, one input chunk at a time): hard-bounded + # to ~1 input chunk + 1 output chunk, race-free, on any chunking. + _tr = getattr(array, "_transform", None) + _trname = type(_tr).__name__ + _local = _parse_storage_location(str(output_path))[0] == "local" + if _trname == "FlattenTransform" and _local: + # disk-staged rechunk-to-flat-contiguous + relabel: read-once source. Per-worker memory + # = region_size_mb; peak ~= max_workers * region_size_mb (same knobs as the region path). + from .rechunk import flatten_write + return flatten_write(_tr.array, output_path, output_chunks=chunks, + max_mem=int(region_size_mb * 1024 * 1024), max_workers=max_workers, + dtype=dtype, zarr_format=zarr_format or 2) + if _trname == "ReshapeTransform" and _local: + # staged: flatten source to 1D contiguous F (read-once, bounded), then reindex F -> + # target shape (contiguous input => no chunk-amplification). Per-worker memory = + # region_size_mb; forwards max_workers. Same knobs as the region path. + from .rechunk import reshape_write + return reshape_write(_tr.array, array.shape, output_path, output_chunks=chunks, + max_mem=int(region_size_mb * 1024 * 1024), max_workers=max_workers, + dtype=dtype, zarr_format=zarr_format or 2) + if _trname == "ScanTransform" and _local: + # bounded-carry streaming scan (tile cross-section, walk the scan axis in strips with a + # running carry): fully memory-bounded, read-once/write-once. Per-call budget = + # region_size_mb (the pull read(key) path stays prefix-bounded for lazy sub-slices). + from .operations.scan import scan_write + return scan_write(_tr.array, _tr.op, _tr.axis, output_path, output_chunks=chunks, + max_mem=int(region_size_mb * 1024 * 1024), + dtype=dtype, zarr_format=zarr_format or 2) import tensorstore as ts if num_readers is None: - num_readers = max(1, max_workers * 2) + # One reader per writer (balanced). The async writes are the bottleneck, so extra + # readers just race ahead and pin more region buffers (peak RAM scales with reader + # count) without improving throughput -- measured 2x readers was both heavier AND + # slower than 1x. Raise num_readers explicitly to hide read latency on slow/remote + # stores. Peak RAM ~= 2 * (num_readers + queue_size + max_inflight_writes) * region_size_mb. + num_readers = max(1, max_workers) # Aggressive memory cleanup before starting gc.collect() @@ -661,9 +690,23 @@ def write_array( print(f"[Optimized] Total regions to process: {total_chunks}, Shape: {input_shape}, Region: {region_shape}", flush=True) - # Queue for work distribution (good for pipeline buffering) + # Queue for work distribution (good for pipeline buffering). + # Peak RAM ~= 2 * (num_readers + queue_size + max_inflight_writes) * region_size_mb. + # The floor of 4 gives read-ahead/write-behind headroom so readers and writers + # overlap even at low worker counts. (Measured: dropping the floor to scale purely + # with max_workers trimmed RAM only at 1 worker, at a throughput cost, and was + # same-or-worse at >=2 workers -- the floor is NOT the memory driver; the staged + # read->queue->async-write pipeline structurally holds more live blocks than dask's + # single-stage model, which is also why it is faster.) Old default was + # min(128, max(32, num_readers)) = 32 -> a ~256MB queue ceiling regardless of size. if queue_size is None: - queue_size = min(128, max(32, num_readers)) + queue_size = max(4, max_workers) + + # Cap concurrent async writes: each pending tensorstore write future pins its + # ~region_size data buffer until it commits, so without this fast submission keeps + # the whole array's buffers alive at once (RSS ~= array size). See writer_thread. + if max_inflight_writes is None: + max_inflight_writes = max(4, 2 * max_workers) chunk_queue = Queue(maxsize=queue_size) sentinel_lock = threading.Lock() @@ -688,6 +731,9 @@ def write_array( def reader_thread(): """Fast producer - no overhead.""" + # Each reader thread owns a CUDA stream so region GPU pipelines overlap (one + # region's compute runs while another's H2D/D2H copies). None on CPU. + gpu_stream = _new_stream(device) try: while not shutdown_flag.is_set(): if state.get('error'): # Check for errors @@ -714,13 +760,21 @@ def reader_thread(): for start, rs, dim_size in zip(chunk_start, region_shape, input_shape) ) - # Read actual data using _read_direct to avoid creating SliceTransform - data = input_array._read_direct(chunk_slice) - + # Read actual data using _read_direct to avoid creating SliceTransform. + # device_context is thread-local, so set it here (in the reader thread) + # so the op chain runs on the requested device for this region; the + # per-thread stream lets regions overlap on the GPU. Then bring the + # region back to host (D2H) since tensorstore writes numpy. + with _use_stream(gpu_stream), _device_context(device): + data = input_array._read_direct(chunk_slice) + # pinned D2H: faster + avoids pinned-staging contention across + # the concurrent reader threads (measured ~7-18% faster on GPU). + data = _asnumpy_pinned(data) + # Convert dtype if needed (allows unsafe casting if explicitly requested) if data.dtype != final_dtype_obj: data = data.astype(final_dtype_obj, copy=False) - + chunk_queue.put((chunk_slice, data)) if current_idx % 10 == 0: @@ -776,8 +830,28 @@ def writer_thread(): # Commit futures immediately to ensure all are tracked with futures_lock: write_futures.append(write_future) - + chunk_queue.task_done() + + # Backpressure + release. Each pending write future pins its + # ~region_size data buffer until it commits, and a *done* future keeps + # pinning it until the future object itself is dropped. So we must both + # (a) prune completed futures here to free their buffers, and (b) block + # while too many writes are still in flight. Without this, buffers for + # the whole array stay alive at once (RSS grows with array size); with + # it, peak RAM stays ~ max_inflight_writes * region_size, array-independent. + while not shutdown_flag.is_set() and not state.get('error'): + with futures_lock: + done = [f for f in write_futures if f.done()] + for f in done: + write_futures.remove(f) + n_inflight = len(write_futures) + for f in done: + f.result() # surface any write error early (already complete) + del done + if n_inflight < max_inflight_writes: + break + time.sleep(0.001) except Exception as e: print(f"[Writer] ERROR: {e}", flush=True) @@ -818,7 +892,12 @@ def monitor_progress(): quarter_checked = False while not stop_monitor.is_set(): - time.sleep(2.0) + # Interruptible wait: Event.wait() returns immediately when stop_monitor is + # set, so a fast write isn't padded by a full sleep interval. Using a plain + # time.sleep(2.0) here left monitor.join(timeout=2.0) blocking ~2s on every + # write (a fixed latency floor that dominated small/medium writes). + if stop_monitor.wait(2.0): + break try: current_read = state['read_idx'] with futures_lock: @@ -1005,12 +1084,15 @@ def write( gc_interval: float = 15.0, early_quarter_timeout: Optional[float] = None, early_tenth_timeout: Optional[float] = None, + max_inflight_writes: Optional[int] = None, + device: Optional[str] = None, **kwargs ): - """Write array to Zarr. See write_array() for details.""" + """Write array to Zarr. See write_array() for details. ``device`` ('cpu'|'cuda') + runs each region's op chain on that device (results are written from host).""" return write_array( array, output_path, max_workers, num_readers, queue_size, chunks, shard_coefficients, dtype, compressor, zarr_format, region_size_mb, gc_interval, early_quarter_timeout, - early_tenth_timeout, **kwargs + early_tenth_timeout, max_inflight_writes, device, **kwargs ) diff --git a/src/dyna_zarr/operations/__init__.py b/src/dyna_zarr/operations/__init__.py new file mode 100644 index 0000000..f46a6b1 --- /dev/null +++ b/src/dyna_zarr/operations/__init__.py @@ -0,0 +1,39 @@ +"""operations: lazy array ops for DynamicArray, decentralized by the ome_zarr_pro +taxonomy locality axis. Each category module owns BOTH its transforms and its public op +functions; this package is a thin flat re-export hub. Adding an op touches exactly one +module. + + from dyna_zarr import operations as ops + ops.add(a, b) # flat + ops.gaussian_filter(x, 2.0) # flat + ops.neighborhood.gaussian_filter(x, 2.0) # grouped access, also available + +Modules by locality: structural (coordinate ops), pointwise (map_blocks + ufuncs), +neighborhood (map_overlap + filters), reductions (min/max, streaming reduce to come). +""" + +from ._base import Transform, _is_int_index, _perm_on_surviving + +# submodules kept importable for grouped access: operations.neighborhood.gaussian_filter, ... +from . import structural, pointwise, reductions, neighborhood, differences, creation, scan + +# ...and re-exported flat: operations.gaussian_filter, operations.add, ... +from .structural import * # noqa: F401,F403 +from .pointwise import * # noqa: F401,F403 +from .reductions import * # noqa: F401,F403 +from .neighborhood import * # noqa: F401,F403 +from .differences import * # noqa: F401,F403 +from .creation import * # noqa: F401,F403 +from .scan import * # noqa: F401,F403 + +__all__ = ( + ["Transform", "structural", "pointwise", "reductions", "neighborhood", + "differences", "creation", "scan"] + + structural.__all__ + + pointwise.__all__ + + reductions.__all__ + + neighborhood.__all__ + + differences.__all__ + + creation.__all__ + + scan.__all__ +) diff --git a/src/dyna_zarr/operations/_backend.py b/src/dyna_zarr/operations/_backend.py new file mode 100644 index 0000000..2351899 --- /dev/null +++ b/src/dyna_zarr/operations/_backend.py @@ -0,0 +1,151 @@ +"""Array-backend seam: let every transform compute with the array module of *its input +block* rather than a hardcoded ``numpy`` / ``scipy``. On CPU the module is numpy and +behaviour is identical; when a region's data is a CuPy array (the GPU device seam, added +separately) the same transforms run on the GPU. + +cupy is detected by module name so importing this file never requires cupy to be present. +Critically, this lets us avoid ``numpy.asarray(gpu_array)`` calls, which would silently +copy device data back to the host mid-pipeline. +""" + +import threading +from contextlib import contextmanager + +import numpy as np + + +def _is_cupy(x) -> bool: + return type(x).__module__.split(".")[0] == "cupy" + + +# --------------------------------------------------------------------------- # +# Execution-device context: the terminal (compute/io.write) sets the default +# device that inherit-ops (device=None) run on; an op's explicit device overrides. +# --------------------------------------------------------------------------- # + +_ctx = threading.local() + + +def current_device() -> str: + return getattr(_ctx, "device", "cpu") + + +@contextmanager +def device_context(device): + """Set the execution device for inherit-ops within the block ('cpu' default).""" + prev = getattr(_ctx, "device", None) + _ctx.device = device or "cpu" + try: + yield + finally: + if prev is None: + _ctx.__dict__.pop("device", None) + else: + _ctx.device = prev + + +def resolve_device(op_device) -> str: + """An op's explicit device if set, else the current execution context.""" + return op_device if op_device is not None else current_device() + + +def to_device(x, device): + """Move array ``x`` to ``device`` ('cpu' | 'cuda' | 'cuda:N'). Scalars pass through; + already-on-target arrays are returned untouched (no copy).""" + if np.isscalar(x) or x is None: + return x + dev = device or "cpu" + if dev == "cpu": + return asnumpy(x) if _is_cupy(x) else x + import cupy + if dev.startswith("cuda:"): + with cupy.cuda.Device(int(dev.split(":", 1)[1])): + return cupy.asarray(x) + return x if _is_cupy(x) else cupy.asarray(x) + + +def array_namespace(*arrays): + """Return the array module (``numpy`` or ``cupy``) for the given operands. Scalars are + ignored; if any operand is a CuPy array, cupy is returned (and imported lazily).""" + for a in arrays: + if _is_cupy(a): + import cupy + return cupy + return np + + +def ndimage_namespace(x): + """Return ``scipy.ndimage`` or ``cupyx.scipy.ndimage`` to match ``x``'s device.""" + if _is_cupy(x): + import cupyx.scipy.ndimage as cndi + return cndi + import scipy.ndimage as ndi + return ndi + + +def asnumpy(x): + """Host numpy array for ``x`` (D2H copy if it lives on the GPU).""" + if _is_cupy(x): + import cupy + return cupy.asnumpy(x) + return np.asarray(x) + + +def asnumpy_pinned(x): + """Like :func:`asnumpy`, but stages the D2H copy into pinned (page-locked) host memory. + + Pinned transfers are ~1.5-2x faster and, more importantly under many concurrent + writer threads, avoid contention on CuPy's shared pinned staging buffer (measured + ~7-18% faster GPU io.write on heavy ops). Used for the streamed region write path; + falls back to :func:`asnumpy` off-GPU or if pinned allocation is unavailable.""" + if not _is_cupy(x): + return np.asarray(x) + try: + import cupyx + out = cupyx.empty_pinned(x.shape, dtype=x.dtype) + x.get(out=out) # D2H into pinned host on the current stream + return out + except Exception: + import cupy + return cupy.asnumpy(x) + + +def is_gpu_array(x) -> bool: + return _is_cupy(x) + + +def xp_for_device(device): + """Array module to *create* new arrays on ``device`` ('cpu'->numpy, 'cuda'->cupy). + Used by generative sources (creation ops) that have no input block to dispatch on.""" + if (device or "cpu") == "cpu": + return np + import cupy + return cupy + + +def new_stream(device): + """A fresh non-blocking CUDA stream for ``device`` ('cuda'/'cuda:N'), else None. + Giving each worker thread its own stream lets regions overlap on the GPU (one + region's compute runs while another's H2D/D2H copies), instead of serialising on + the shared default stream.""" + dev = device or "cpu" + if dev == "cpu": + return None + try: + import cupy + if dev.startswith("cuda:"): + with cupy.cuda.Device(int(dev.split(":", 1)[1])): + return cupy.cuda.Stream(non_blocking=True) + return cupy.cuda.Stream(non_blocking=True) + except Exception: + return None + + +@contextmanager +def use_stream(stream): + """Activate ``stream`` as the current CUDA stream for the block (no-op if None).""" + if stream is None: + yield + else: + with stream: + yield diff --git a/src/dyna_zarr/operations/_base.py b/src/dyna_zarr/operations/_base.py new file mode 100644 index 0000000..16b162c --- /dev/null +++ b/src/dyna_zarr/operations/_base.py @@ -0,0 +1,46 @@ +"""Transform base class and shared slice-math helpers for the operations package.""" + +import numpy as np +from typing import Tuple, Union, List, Optional, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from dyna_zarr.dynamic_array import DynamicArray + + +def _is_int_index(k) -> bool: + """True if key element is an integer index (drops its axis), not a slice/newaxis.""" + return isinstance(k, (int, np.integer)) + + +def _perm_on_surviving(result: np.ndarray, out_key, axes) -> np.ndarray: + """Reorder ``result`` into output-axis order for a transpose/swapaxes read. + + ``result`` was read from the underlying array with axes in *input* order, with + integer-indexed axes already dropped by the read. ``out_key`` is the output-space + key (len == len(axes)); output position ``i`` maps to input axis ``axes[i]`` and + ``out_key[i]`` applies to it. We transpose ``result`` into output order, restricted + to the axes that survive integer indexing. Reduces to ``np.transpose(result, axes)`` + when the key contains no integer indices. + """ + ndim = len(axes) + dropped = {axes[i] for i in range(ndim) if _is_int_index(out_key[i])} + surviving = [ia for ia in range(ndim) if ia not in dropped] + pos = {ia: j for j, ia in enumerate(surviving)} + desired = [axes[i] for i in range(ndim) if not _is_int_index(out_key[i])] + return np.transpose(result, [pos[ia] for ia in desired]) + + +class Transform: + """ + Base class for lazy transformations. + """ + + def __init__(self): + self.shape = None + self.chunks = None + self.dtype = None + + def read(self, key): + raise NotImplementedError + + diff --git a/src/dyna_zarr/operations/creation.py b/src/dyna_zarr/operations/creation.py new file mode 100644 index 0000000..ed56e2e --- /dev/null +++ b/src/dyna_zarr/operations/creation.py @@ -0,0 +1,163 @@ +"""Creation ops (nullary generative sources): zeros/ones/full/empty/random (+ *_like). + +These build a lazy DynamicArray from a shape/dtype with no underlying array. The +GenerativeTransform *synthesizes* whatever region a read asks for, so creation stays +lazy and memory-bound (a huge ``zeros`` never allocates the whole thing) and composes +with every other op. It also honours the execution device: under ``compute(device='cuda')`` +a ``zeros`` region is created directly on the GPU. + +``random`` is **position-deterministic**: each element's value is a hash of its GLOBAL +index (and a per-array seed), so it is idempotent and chunk-invariant -- reading any region +gives the same values as the whole array sliced, and ``io.write(random(...))`` equals +``random(...).compute()``. (Unlike dask.array.random, whose values depend on the chunking.) +""" + +import numpy as np + +from ._base import Transform, _is_int_index +from ._backend import resolve_device, xp_for_device, to_device + + +class GenerativeTransform(Transform): + """A nullary source: ``read(key)`` synthesizes the requested region.""" + + def __init__(self, shape, dtype, chunks, mode, fill_value=0, seed=None, device=None): + super().__init__() + self.shape = tuple(int(s) for s in shape) + self.dtype = np.dtype(dtype) + self.chunks = tuple(chunks) if chunks is not None else self.shape + self.mode = mode # 'zeros' | 'ones' | 'full' | 'empty' | 'random' + self.fill_value = fill_value + self.seed = seed + self._seed64 = (int(seed) & 0xFFFFFFFFFFFFFFFF) if seed is not None else 0 + self.device = device + + def _axes(self, key): + """Per input axis: (is_int, start, step, out_size) with concrete non-negatives.""" + ndim = len(self.shape) + if not isinstance(key, tuple): + key = (key,) + key = key + (slice(None),) * (ndim - len(key)) + axes = [] + for a in range(ndim): + k = key[a] + size = self.shape[a] + if _is_int_index(k): + idx = int(k) if k >= 0 else size + int(k) + axes.append((True, idx, 1, None)) + else: + start, stop, step = k.indices(size) + axes.append((False, start, step, len(range(start, stop, step)))) + return axes + + def _random_region(self, axes, out_shape): + """Position-deterministic uniform [0,1): value = splitmix64(global_flat_index, seed). + Built with numpy (portable, exact); moved to the device by the caller.""" + ndim = len(self.shape) + strides = [1] * ndim + for a in range(ndim - 2, -1, -1): + strides[a] = strides[a + 1] * self.shape[a + 1] + # Work IN-PLACE in a single uint64 buffer `z` (+ one scratch `t`) so we don't + # allocate several full-region arrays -- important for large chunks. + z = np.full(out_shape, np.uint64(0x9E3779B97F4A7C15) + np.uint64(self._seed64), + dtype=np.uint64) + oi = 0 + for a, (is_int, start, step, n) in enumerate(axes): + stride = np.uint64(strides[a]) + if is_int: + z += np.uint64(start) * stride + else: + coord = (np.uint64(start) + np.arange(n, dtype=np.uint64) * np.uint64(step)) * stride + shp = [1] * len(out_shape) + shp[oi] = n + z += coord.reshape(shp) # broadcast add, in place + oi += 1 + t = z >> np.uint64(30); z ^= t; z *= np.uint64(0xBF58476D1CE4E5B9) + np.right_shift(z, np.uint64(27), out=t); z ^= t; z *= np.uint64(0x94D049BB133111EB) + np.right_shift(z, np.uint64(31), out=t); z ^= t + del t + z >>= np.uint64(11) + u = z.astype(np.float64); u *= (1.0 / 9007199254740992.0) # /2**53 -> [0,1) + return u.astype(self.dtype, copy=False) + + def read(self, key): + axes = self._axes(key) + out_shape = tuple(n for (is_int, _s, _st, n) in axes if not is_int) + dev = resolve_device(self.device) + if self.mode == "random": + return to_device(self._random_region(axes, out_shape), dev) + xp = xp_for_device(dev) + if self.mode == "zeros": + return xp.zeros(out_shape, dtype=self.dtype) + if self.mode == "ones": + return xp.ones(out_shape, dtype=self.dtype) + if self.mode == "full": + return xp.full(out_shape, self.fill_value, dtype=self.dtype) + return xp.empty(out_shape, dtype=self.dtype) # 'empty' (uninitialized) + + +def _make(shape, dtype, chunks, mode, fill_value=0, seed=None, device=None): + from dyna_zarr.dynamic_array import DynamicArray + if np.isscalar(shape): + shape = (int(shape),) + t = GenerativeTransform(shape, dtype, chunks, mode, fill_value, seed, device) + return DynamicArray._from_transform(t) + + +# --------------------------------------------------------------------------- # +# Public ops +# --------------------------------------------------------------------------- # + +def zeros(shape, dtype=np.float64, chunks=None, device=None): + """Lazy array of zeros.""" + return _make(shape, dtype, chunks, "zeros", device=device) + + +def ones(shape, dtype=np.float64, chunks=None, device=None): + """Lazy array of ones.""" + return _make(shape, dtype, chunks, "ones", device=device) + + +def full(shape, fill_value, dtype=None, chunks=None, device=None): + """Lazy array filled with ``fill_value``.""" + if dtype is None: + dtype = np.array(fill_value).dtype + return _make(shape, dtype, chunks, "full", fill_value=fill_value, device=device) + + +def empty(shape, dtype=np.float64, chunks=None, device=None): + """Lazy uninitialized array (values are undefined; each read is fresh garbage).""" + return _make(shape, dtype, chunks, "empty", device=device) + + +def random(shape, dtype=np.float32, chunks=None, seed=None, device=None): + """Lazy uniform-random array in [0, 1), position-deterministic (chunk-invariant, so + reads are consistent and io.write matches compute). ``seed`` fixes the values; if None, + a fresh random seed is drawn once so separate ``random(...)`` calls differ (but each + array is internally consistent).""" + if seed is None: + seed = int(np.random.SeedSequence().generate_state(1, dtype=np.uint64)[0]) + return _make(shape, dtype, chunks, "random", seed=seed, device=device) + + +def zeros_like(array, dtype=None, chunks=None, device=None): + return zeros(array.shape, dtype=dtype or array.dtype, chunks=chunks or array.chunks, device=device) + + +def ones_like(array, dtype=None, chunks=None, device=None): + return ones(array.shape, dtype=dtype or array.dtype, chunks=chunks or array.chunks, device=device) + + +def full_like(array, fill_value, dtype=None, chunks=None, device=None): + return full(array.shape, fill_value, dtype=dtype or array.dtype, chunks=chunks or array.chunks, device=device) + + +def empty_like(array, dtype=None, chunks=None, device=None): + return empty(array.shape, dtype=dtype or array.dtype, chunks=chunks or array.chunks, device=device) + + +__all__ = [ + "GenerativeTransform", + "zeros", "ones", "full", "empty", "random", + "zeros_like", "ones_like", "full_like", "empty_like", +] diff --git a/src/dyna_zarr/operations/differences.py b/src/dyna_zarr/operations/differences.py new file mode 100644 index 0000000..a935bdb --- /dev/null +++ b/src/dyna_zarr/operations/differences.py @@ -0,0 +1,39 @@ +"""Discrete differences: diff and gradient (halo-1 neighbourhood ops along an axis).""" + +from ._backend import array_namespace +from .pointwise import subtract +from .neighborhood import map_overlap + + +def diff(array, n=1, axis=-1): + """Discrete difference along ``axis`` (``out[i] = a[i+1] - a[i]``), ``n`` times. + + Composed from slicing + subtract, so it stays lazy and memory-bound: each read pulls + two adjacent strips. The axis shrinks by ``n`` (reductive, like numpy.diff). + """ + ax = axis if axis >= 0 else array.ndim + axis + for _ in range(int(n)): + nd = array.ndim + m = array.shape[ax] # explicit positive bounds: SliceTransform + front = array[tuple(slice(1, m) if i == ax else slice(None) for i in range(nd))] + back = array[tuple(slice(0, m - 1) if i == ax else slice(None) for i in range(nd))] + array = subtract(front, back) + return array + + +def gradient(array, axis=-1, device=None): + """Central-difference gradient along a single ``axis`` (shape-preserving, like + numpy.gradient with edge_order=1). + + A depth-1 map_overlap: interior pixels get true central differences (the halo supplies + neighbours across region seams); the true array edges use ``odd_reflect`` padding + (linear extrapolation ``2*edge - inner``), which makes the central difference there + equal numpy's one-sided edge difference -- so it matches numpy exactly, everywhere. + """ + ax = axis if axis >= 0 else array.ndim + axis + depth = tuple(1 if i == ax else 0 for i in range(array.ndim)) + func = lambda b: array_namespace(b).gradient(b, axis=ax) + return map_overlap(array, func, depth, boundary="odd_reflect", name="gradient", device=device) + + +__all__ = ["diff", "gradient"] diff --git a/src/dyna_zarr/operations/neighborhood.py b/src/dyna_zarr/operations/neighborhood.py new file mode 100644 index 0000000..8729729 --- /dev/null +++ b/src/dyna_zarr/operations/neighborhood.py @@ -0,0 +1,279 @@ +"""Neighbourhood primitive (pull-model ``map_overlap``) and the filters built on it. + +A neighbourhood op is shape-preserving but each output element depends on a local window +of the input (halo/``depth``). In the pull model this is just map_blocks with a widened +read window: to produce the block for ``key`` we read ``key`` expanded by ``depth`` on +every side (padding at the true array edges per ``boundary``), apply the (shape-preserving) +function to that expanded block, then crop the halo back off. Because every read pulls its +own halo, the result is independent of the region size that drove it -- i.e. chunk/region +invariant, and exact vs applying the function to the whole array (given ``depth`` >= the +function's radius and a matching ``boundary``). + +This is the ``conservative . unary . neighbourhood`` taxonomy cell. +""" + +import numpy as np +from typing import Optional, Union, Tuple, Dict + +from ._base import Transform, _is_int_index +from ._backend import array_namespace, ndimage_namespace, resolve_device, to_device + + +# boundary name (scipy.ndimage convention) -> (numpy.pad mode, extra pad kwargs) for the +# true array edges. +_BOUNDARY_TO_NPPAD = { + "reflect": ("symmetric", {}), # scipy 'reflect': (d c b a | a b c d) -- edge duplicated + "mirror": ("reflect", {}), # scipy 'mirror': (d c b | a b c d) -- edge not duplicated + "nearest": ("edge", {}), + "wrap": ("wrap", {}), + "constant": ("constant", {}), + # odd-reflection = linear extrapolation (2*edge - inner); makes a central difference at + # the boundary equal the one-sided difference, so gradient() matches numpy at the edges. + "odd_reflect": ("reflect", {"reflect_type": "odd"}), +} + + +def _normalize_depth(depth, ndim) -> Tuple[int, ...]: + """Normalize ``depth`` to a per-axis tuple of symmetric halo widths.""" + if isinstance(depth, dict): + return tuple(int(depth.get(a, 0)) for a in range(ndim)) + if np.isscalar(depth): + return (int(depth),) * ndim + depth = tuple(int(d) for d in depth) + if len(depth) != ndim: + raise ValueError(f"depth {depth} does not match array ndim {ndim}") + return depth + + +def _infer_overlap_dtype(func, array): + """Infer output dtype by applying func to a small sample of the array's dtype.""" + from ..utils import parse_dtype + np_dt = parse_dtype(array.dtype)[0] + sample_shape = tuple(min(s, 3) for s in array.shape) + try: + return np.asarray(func(np.ones(sample_shape, dtype=np_dt))).dtype + except Exception: + return np_dt + + +class MapOverlapTransform(Transform): + """Apply a shape-preserving neighbourhood ``func`` with a ``depth`` halo, lazily. + + ``func`` receives an expanded block (input window padded to include the halo) and must + return an array of the *same shape*; the halo is then trimmed. ``depth`` may be an int + (all axes), a per-axis sequence, or a ``{axis: depth}`` dict. ``boundary`` (scipy.ndimage + names: reflect/mirror/nearest/wrap/constant) controls how the true array edges are + extended. For an exact match to ``func`` applied to the whole array, ``depth`` must be + >= the function's radius and ``boundary`` must match the function's edge mode. + """ + + def __init__(self, array, func, depth, boundary="reflect", dtype=None, name=None, + device=None): + super().__init__() + if boundary not in _BOUNDARY_TO_NPPAD: + raise ValueError(f"unknown boundary {boundary!r}; expected one of " + f"{sorted(_BOUNDARY_TO_NPPAD)}") + self.array = array + self.func = func + self.device = device + self.name = name or getattr(func, "__name__", "map_overlap") + self.depth = _normalize_depth(depth, array.ndim) + self.boundary = boundary + self.shape = array.shape + self.chunks = array.chunks + self.dtype = np.dtype(dtype) if dtype is not None else _infer_overlap_dtype(func, array) + + def read(self, key): + ndim = self.array.ndim + if not isinstance(key, tuple): + key = (key,) + key = key + (slice(None),) * (ndim - len(key)) + + read_slices = [] # region to read from the input (clamped to bounds, step 1) + pad_widths = [] # (before, after) padding that restores the halo at true edges + crop_slices = [] # crop the func output back to the core region + apply step + squeeze_axes = [] + for a in range(ndim): + k = key[a] + size = self.array.shape[a] + d = self.depth[a] + if _is_int_index(k): + idx = int(k) if k >= 0 else size + int(k) + start, stop, step = idx, idx + 1, 1 + squeeze_axes.append(a) + else: + start, stop, step = k.indices(size) + if step < 0: + raise NotImplementedError("map_overlap: negative-step reads not supported") + + # desired expanded span [start-d, stop+d), clamped to [0, size) + read_slices.append(slice(max(0, start - d), min(size, stop + d))) + pad_widths.append((max(0, d - start), max(0, (stop + d) - size))) + # after padding, the block has the core at [d : d+core_len]; then apply the step + crop_slices.append(slice(d, d + (stop - start), step)) + + block = to_device(self.array._read_direct(tuple(read_slices)), + resolve_device(self.device)) + xp = array_namespace(block) + if any(pb or pa for pb, pa in pad_widths): + mode, pad_kw = _BOUNDARY_TO_NPPAD[self.boundary] + block = xp.pad(block, pad_widths, mode=mode, **pad_kw) + + out = self.func(block) # func dispatches ndimage on block's device + out = out[tuple(crop_slices)] + for a in sorted(squeeze_axes, reverse=True): + out = xp.squeeze(out, axis=a) + return out + + +# --------------------------------------------------------------------------- # +# depth helpers for the scipy-backed filters +# --------------------------------------------------------------------------- # + +def _as_per_axis(value, ndim): + if np.isscalar(value): + return (value,) * ndim + value = tuple(value) + if len(value) != ndim: + raise ValueError(f"expected a scalar or length-{ndim} sequence, got {value}") + return value + + +def _gaussian_depth(sigma, ndim, truncate): + sig = _as_per_axis(sigma, ndim) + # scipy's 1-D Gaussian half-width: int(truncate * sigma + 0.5) + return tuple(int(truncate * float(s) + 0.5) for s in sig) + + +def _size_depth(size, ndim): + sz = _as_per_axis(size, ndim) + # max half-extent of a length-n window (covers even sizes too) + return tuple(int(n) // 2 for n in sz) + + +# --------------------------------------------------------------------------- # +# Public ops -- map_overlap primitive + scipy-backed neighbourhood filters +# --------------------------------------------------------------------------- # + +def map_overlap(array, func, depth, boundary="reflect", dtype=None, name=None, device=None): + """Apply a shape-preserving neighbourhood ``func`` with a ``depth`` halo, lazily and + chunk-invariantly. Every read pulls its own halo, so the result is independent of the + region size and exact vs applying ``func`` to the whole array (given depth >= radius and + matching ``boundary``). The filters below wrap this. ``device`` (None=inherit, 'cpu', + 'cuda') runs it on that device.""" + return array._with_transform( + MapOverlapTransform(array, func, depth, boundary=boundary, dtype=dtype, + name=name, device=device) + ) + + +def gaussian_filter(array, sigma, boundary="reflect", truncate=4.0, device=None, **kw): + depth = _gaussian_depth(sigma, array.ndim, truncate) + func = lambda b: ndimage_namespace(b).gaussian_filter( + b, sigma=sigma, mode=boundary, truncate=truncate, **kw) + return map_overlap(array, func, depth, boundary=boundary, name="gaussian_filter", device=device) + + +def uniform_filter(array, size, boundary="reflect", device=None, **kw): + func = lambda b: ndimage_namespace(b).uniform_filter(b, size=size, mode=boundary, **kw) + return map_overlap(array, func, _size_depth(size, array.ndim), boundary=boundary, + name="uniform_filter", device=device) + + +def median_filter(array, size, boundary="reflect", device=None, **kw): + func = lambda b: ndimage_namespace(b).median_filter(b, size=size, mode=boundary, **kw) + return map_overlap(array, func, _size_depth(size, array.ndim), boundary=boundary, + name="median_filter", device=device) + + +def minimum_filter(array, size, boundary="reflect", device=None, **kw): + func = lambda b: ndimage_namespace(b).minimum_filter(b, size=size, mode=boundary, **kw) + return map_overlap(array, func, _size_depth(size, array.ndim), boundary=boundary, + name="minimum_filter", device=device) + + +def maximum_filter(array, size, boundary="reflect", device=None, **kw): + func = lambda b: ndimage_namespace(b).maximum_filter(b, size=size, mode=boundary, **kw) + return map_overlap(array, func, _size_depth(size, array.ndim), boundary=boundary, + name="maximum_filter", device=device) + + +def grey_erosion(array, size, boundary="reflect", device=None, **kw): + func = lambda b: ndimage_namespace(b).grey_erosion(b, size=size, mode=boundary, **kw) + return map_overlap(array, func, _size_depth(size, array.ndim), boundary=boundary, + name="grey_erosion", device=device) + + +def grey_dilation(array, size, boundary="reflect", device=None, **kw): + func = lambda b: ndimage_namespace(b).grey_dilation(b, size=size, mode=boundary, **kw) + return map_overlap(array, func, _size_depth(size, array.ndim), boundary=boundary, + name="grey_dilation", device=device) + + +def _kernel_depth(weights, ndim): + """Halo per axis for a convolution/correlation kernel: half the kernel size (>= the + kernel's reach on either side, so map_overlap stays exact vs the whole-array result).""" + w = np.asarray(weights) + if w.ndim != ndim: + raise ValueError( + f"weights ndim {w.ndim} must match array ndim {ndim}; use size-1 axes to leave " + f"an axis untouched (e.g. a (1, ky, kx) kernel over a (z, y, x) volume)" + ) + return w, tuple(int(s) // 2 for s in w.shape) + + +def convolve(array, weights, boundary="reflect", cval=0.0, device=None): + """Multidimensional convolution with a ``weights`` kernel (like scipy.ndimage.convolve). + ``weights`` must have the same ndim as ``array`` (size-1 axes leave an axis untouched). + The kernel is moved to the block's namespace, so this runs on CPU or GPU per ``device``.""" + w, depth = _kernel_depth(weights, array.ndim) + func = lambda b: ndimage_namespace(b).convolve( + b, array_namespace(b).asarray(w), mode=boundary, cval=cval) + return map_overlap(array, func, depth, boundary=boundary, name="convolve", device=device) + + +def correlate(array, weights, boundary="reflect", cval=0.0, device=None): + """Multidimensional cross-correlation with a ``weights`` kernel (like + scipy.ndimage.correlate). Same as ``convolve`` but the kernel is not flipped.""" + w, depth = _kernel_depth(weights, array.ndim) + func = lambda b: ndimage_namespace(b).correlate( + b, array_namespace(b).asarray(w), mode=boundary, cval=cval) + return map_overlap(array, func, depth, boundary=boundary, name="correlate", device=device) + + +def laplace(array, boundary="reflect", device=None, **kw): + """Laplace filter via the second-derivative [1, -2, 1] stencil (scipy.ndimage.laplace). + Fixed radius 1 on every axis.""" + func = lambda b: ndimage_namespace(b).laplace(b, mode=boundary, **kw) + return map_overlap(array, func, (1,) * array.ndim, boundary=boundary, + name="laplace", device=device) + + +def gaussian_laplace(array, sigma, boundary="reflect", truncate=4.0, device=None, **kw): + """Laplace of Gaussian (scipy.ndimage.gaussian_laplace). Halo is the Gaussian radius + ``int(truncate * sigma + 0.5)`` per axis, as for ``gaussian_filter``.""" + depth = _gaussian_depth(sigma, array.ndim, truncate) + func = lambda b: ndimage_namespace(b).gaussian_laplace( + b, sigma=sigma, mode=boundary, truncate=truncate, **kw) + return map_overlap(array, func, depth, boundary=boundary, + name="gaussian_laplace", device=device) + + +def gaussian_gradient_magnitude(array, sigma, boundary="reflect", truncate=4.0, + device=None, **kw): + """Gradient magnitude using Gaussian derivatives (scipy.ndimage. + gaussian_gradient_magnitude). Halo is the Gaussian radius per axis.""" + depth = _gaussian_depth(sigma, array.ndim, truncate) + func = lambda b: ndimage_namespace(b).gaussian_gradient_magnitude( + b, sigma=sigma, mode=boundary, truncate=truncate, **kw) + return map_overlap(array, func, depth, boundary=boundary, + name="gaussian_gradient_magnitude", device=device) + + +__all__ = [ + "MapOverlapTransform", "map_overlap", + "gaussian_filter", "uniform_filter", "median_filter", + "minimum_filter", "maximum_filter", "grey_erosion", "grey_dilation", + "convolve", "correlate", + "laplace", "gaussian_laplace", "gaussian_gradient_magnitude", +] diff --git a/src/dyna_zarr/operations/pointwise.py b/src/dyna_zarr/operations/pointwise.py new file mode 100644 index 0000000..4b201d7 --- /dev/null +++ b/src/dyna_zarr/operations/pointwise.py @@ -0,0 +1,173 @@ +"""Pull-model map_blocks primitive: elementwise (chunk-independent) ops, memory-bound.""" + +import numpy as np +from typing import Tuple, Union, List, Optional, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from dyna_zarr.dynamic_array import DynamicArray + + +from ._base import Transform +from ._backend import array_namespace, resolve_device, to_device + + +def _infer_mapblocks_dtype(func, operands): + """Infer an elementwise func's output dtype by applying it to 1-element samples of + each operand's dtype (scalars passed through), letting NumPy type promotion decide. + + Operand dtypes may be tensorstore dtypes (io.read yields TS-backed arrays), which + numpy can't consume directly, so normalise via parse_dtype first.""" + from dyna_zarr.dynamic_array import DynamicArray + from ..utils import parse_dtype + samples = [ + np.ones((1,), dtype=parse_dtype(o.dtype)[0]) if isinstance(o, DynamicArray) else o + for o in operands + ] + return np.asarray(func(*samples)).dtype + + +class MapBlocksTransform(Transform): + """Pull-model ``map_blocks``: apply an ELEMENTWISE (chunk-independent) function to + one or more equally-shaped DynamicArray operands (plus scalars). + + Because the function is pointwise, ``func(a[key], b[key]) == func(a, b)[key]``, so + reading an arbitrary output slice just reads that slice from each operand and applies + the function -- nothing is materialised, so it stays memory-bound. This is the + ``conservative . pointwise`` taxonomy cell; it is NOT valid for neighbourhood or + reducing functions (use map_overlap / reduce for those). + + Operands may mix DynamicArrays (read blockwise) and plain scalars (passed through; + NumPy broadcasts them). All DynamicArray operands must share the same shape. + """ + + def __init__(self, func, operands, dtype=None, name=None, device=None): + super().__init__() + from dyna_zarr.dynamic_array import DynamicArray + self.func = func + self.operands = list(operands) + self.device = device + self.name = name or getattr(func, "__name__", "map_blocks") + arrays = [o for o in self.operands if isinstance(o, DynamicArray)] + if not arrays: + raise ValueError("map_blocks requires at least one DynamicArray operand") + ref = arrays[0] + for a in arrays[1:]: + if a.shape != ref.shape: + raise ValueError( + f"map_blocks operands must share shape; got {a.shape} vs {ref.shape}" + ) + self.shape = ref.shape + self.chunks = ref.chunks + self.dtype = dtype if dtype is not None else _infer_mapblocks_dtype(func, self.operands) + + def read(self, key): + from dyna_zarr.dynamic_array import DynamicArray + dev = resolve_device(self.device) + blocks = [ + to_device(o._read_direct(key), dev) if isinstance(o, DynamicArray) + else o + for o in self.operands + ] + # xp.asarray keeps the result on its own device (numpy or cupy); a plain + # np.asarray would force a GPU->host copy mid-pipeline. + xp = array_namespace(*blocks) + return xp.asarray(self.func(*blocks)) + + +# --------------------------------------------------------------------------- # +# Public ops -- pointwise ufuncs, all thin wrappers over map_blocks +# --------------------------------------------------------------------------- # + +def map_blocks(func, *operands, dtype=None, name=None, device=None): + """Apply an elementwise (chunk-independent) ``func`` to one or more equally-shaped + DynamicArrays (plus scalars), lazily and memory-bound. Every pointwise op here (ufuncs, + comparisons, logical ops, where, clip, astype) is a thin wrapper over this. ``func`` + receives the read block of each operand; for neighbourhood/reducing funcs use + map_overlap / reduce. ``device`` (None=inherit the execution context, 'cpu', 'cuda') + runs this op on that device.""" + from dyna_zarr.dynamic_array import DynamicArray + transform = MapBlocksTransform(func, operands, dtype=dtype, name=name, device=device) + ref = next(o for o in operands if isinstance(o, DynamicArray)) + return ref._with_transform(transform) + + +# unary +def abs(array): return map_blocks(np.abs, array, name="abs") +def negative(array): return map_blocks(np.negative, array, name="negative") +def sign(array): return map_blocks(np.sign, array, name="sign") +def sqrt(array): return map_blocks(np.sqrt, array, name="sqrt") +def square(array): return map_blocks(np.square, array, name="square") +def exp(array): return map_blocks(np.exp, array, name="exp") +def log(array): return map_blocks(np.log, array, name="log") +def log2(array): return map_blocks(np.log2, array, name="log2") +def log10(array): return map_blocks(np.log10, array, name="log10") +def floor(array): return map_blocks(np.floor, array, name="floor") +def ceil(array): return map_blocks(np.ceil, array, name="ceil") +def reciprocal(array): return map_blocks(np.reciprocal, array, name="reciprocal") + + +def round(array, decimals=0): + return map_blocks(lambda x: np.round(x, decimals=decimals), array, name="round") + + +def clip(array, a_min=None, a_max=None): + return map_blocks(lambda x: np.clip(x, a_min, a_max), array, name="clip") + + +def astype(array, dtype): + return map_blocks(lambda x: x.astype(dtype), array, dtype=np.dtype(dtype), name="astype") + + +# binary +def add(a, b): return map_blocks(np.add, a, b, name="add") +def subtract(a, b): return map_blocks(np.subtract, a, b, name="subtract") +def multiply(a, b): return map_blocks(np.multiply, a, b, name="multiply") +def divide(a, b): return map_blocks(np.divide, a, b, name="divide") +def floor_divide(a, b): return map_blocks(np.floor_divide, a, b, name="floor_divide") +def mod(a, b): return map_blocks(np.mod, a, b, name="mod") +def power(a, b): return map_blocks(np.power, a, b, name="power") +def maximum(a, b): return map_blocks(np.maximum, a, b, name="maximum") +def minimum(a, b): return map_blocks(np.minimum, a, b, name="minimum") + + +# comparisons & logical (bool out) +def greater(a, b): return map_blocks(np.greater, a, b, name="greater") +def greater_equal(a, b): return map_blocks(np.greater_equal, a, b, name="greater_equal") +def less(a, b): return map_blocks(np.less, a, b, name="less") +def less_equal(a, b): return map_blocks(np.less_equal, a, b, name="less_equal") +def equal(a, b): return map_blocks(np.equal, a, b, name="equal") +def not_equal(a, b): return map_blocks(np.not_equal, a, b, name="not_equal") +def logical_and(a, b): return map_blocks(np.logical_and, a, b, name="logical_and") +def logical_or(a, b): return map_blocks(np.logical_or, a, b, name="logical_or") +def logical_xor(a, b): return map_blocks(np.logical_xor, a, b, name="logical_xor") +def logical_not(array): return map_blocks(np.logical_not, array, name="logical_not") + + +# ternary +def where(condition, x, y): return map_blocks(np.where, condition, x, y, name="where") + + +# lookup / binning (pointwise; func resolves the array module of the block so it runs on +# whichever device the block lives on) +def isin(array, test_elements, invert=False): + """Elementwise membership test against ``test_elements`` (bool output).""" + return map_blocks(lambda x: array_namespace(x).isin(x, test_elements, invert=invert), + array, dtype=bool, name="isin") + + +def digitize(array, bins, right=False): + """Index of the bin each element falls into (like numpy.digitize).""" + return map_blocks(lambda x: array_namespace(x).digitize(x, bins, right=right), + array, dtype=np.intp, name="digitize") + + +__all__ = [ + "MapBlocksTransform", "map_blocks", + "abs", "negative", "sign", "sqrt", "square", "exp", "log", "log2", "log10", + "floor", "ceil", "reciprocal", "round", "clip", "astype", + "add", "subtract", "multiply", "divide", "floor_divide", "mod", "power", + "maximum", "minimum", + "greater", "greater_equal", "less", "less_equal", "equal", "not_equal", + "logical_and", "logical_or", "logical_xor", "logical_not", "where", + "isin", "digitize", +] diff --git a/src/dyna_zarr/operations/reductions.py b/src/dyna_zarr/operations/reductions.py new file mode 100644 index 0000000..aeb084e --- /dev/null +++ b/src/dyna_zarr/operations/reductions.py @@ -0,0 +1,381 @@ +"""Reductions -- the reductive/destructive taxonomy cell. + +A reduction collapses one or more axes. The pull model can't cheaply slice a *reduced* +axis (the output element depends on the whole axis), so ``read(key)`` reads only the +kept-axis tile the key asks for but **streams the reduced axis in bounded chunks**, +combining them with an associative reducer (min/max/sum/prod/mean/any/all). That keeps it +memory-bound even for a huge reduced axis, and the result is chunk/region-invariant and +exact vs numpy. Step/integer indices on the output are applied after the reduce (same +crop-at-the-end trick as map_overlap). + +``axis=None`` reduces everything to a 0-d result, still streamed. The result is a lazy +DynamicArray (chainable / writable); DynamicArray.min()/max() wrap it and compute eagerly. +""" + +import builtins +import numpy as np +from typing import Optional, Tuple + +from ._base import Transform, _is_int_index +from ._backend import array_namespace, asnumpy, resolve_device, to_device + +_DEFAULT_STRIP_BYTES = 64 * 1024 * 1024 # per-read streaming budget for the reduced axis + + +def _ident(xp, s, count, ddof): + return s + + +def _div_count(xp, s, count, ddof): + return s / count + + +def _var_partial(xp, b, ax): + # accumulate in float64 so the one-pass sum-of-squares identity stays accurate + bf = b.astype(xp.float64) + return (xp.sum(bf, axis=ax), xp.sum(bf * bf, axis=ax)) + + +def _pair_add(xp, a, b): + return (a[0] + b[0], a[1] + b[1]) + + +def _var_finalize(xp, s, count, ddof): + ssum, ssq = s + var = (ssq - ssum * ssum / count) / (count - ddof) + return xp.clip(var, 0.0, None) # guard tiny negatives from float error + + +def _std_finalize(xp, s, count, ddof): + return xp.sqrt(_var_finalize(xp, s, count, ddof)) + + +class _Reducer: + """Associative reducers use partial(xp, block, axes)->state, combine(xp, a, b)->state, + finalize(xp, state, count, ddof)->result and are streamed over the reduced axis. + Non-associative reducers (argmin/argmax) can't be chunked on the reduced axis, so they + read it whole per kept-tile and apply direct(xp, block, axes) in one shot.""" + def __init__(self, partial=None, combine=None, finalize=_ident, + associative=True, direct=None): + self.partial = partial + self.combine = combine + self.finalize = finalize + self.associative = associative + self.direct = direct + + +def _arg_axis(ax): + if len(ax) == 1: + return ax[0] + if len(ax) == 0: + return None + raise ValueError("argmin/argmax take a single axis or axis=None") + + +_REDUCERS = { + "min": _Reducer(lambda xp, b, ax: xp.min(b, axis=ax), lambda xp, a, b: xp.minimum(a, b)), + "max": _Reducer(lambda xp, b, ax: xp.max(b, axis=ax), lambda xp, a, b: xp.maximum(a, b)), + "sum": _Reducer(lambda xp, b, ax: xp.sum(b, axis=ax), lambda xp, a, b: xp.add(a, b)), + "prod": _Reducer(lambda xp, b, ax: xp.prod(b, axis=ax), lambda xp, a, b: xp.multiply(a, b)), + "any": _Reducer(lambda xp, b, ax: xp.any(b, axis=ax), lambda xp, a, b: xp.logical_or(a, b)), + "all": _Reducer(lambda xp, b, ax: xp.all(b, axis=ax), lambda xp, a, b: xp.logical_and(a, b)), + "mean": _Reducer(lambda xp, b, ax: xp.sum(b, axis=ax), lambda xp, a, b: xp.add(a, b), _div_count), + "var": _Reducer(_var_partial, _pair_add, _var_finalize), + "std": _Reducer(_var_partial, _pair_add, _std_finalize), + # non-associative: read the reduced axis whole per kept-tile, apply in one shot + "argmin": _Reducer(associative=False, direct=lambda xp, b, ax: xp.argmin(b, axis=_arg_axis(ax))), + "argmax": _Reducer(associative=False, direct=lambda xp, b, ax: xp.argmax(b, axis=_arg_axis(ax))), + "median": _Reducer(associative=False, direct=lambda xp, b, ax: xp.median(b, axis=ax)), +} + + +def _normalize_axes(axis, ndim) -> Tuple[int, ...]: + if axis is None: + return tuple(range(ndim)) + if np.isscalar(axis): + axis = (axis,) + out = [] + for a in axis: + a = int(a) if a >= 0 else ndim + int(a) + if a < 0 or a >= ndim: + raise ValueError(f"axis {a} out of bounds for ndim {ndim}") + out.append(a) + return tuple(sorted(set(out))) + + +class ReduceTransform(Transform): + """Lazy, streaming reduction over one or more axes.""" + + def __init__(self, array, reducer, axis=None, keepdims=False, + strip_bytes=_DEFAULT_STRIP_BYTES, device=None, ddof=0): + super().__init__() + if reducer not in _REDUCERS: + raise ValueError(f"unknown reducer {reducer!r}; expected {sorted(_REDUCERS)}") + from ..utils import parse_dtype + self.array = array + self.reducer_name = reducer + self.reducer = _REDUCERS[reducer] + self.keepdims = keepdims + self.strip_bytes = strip_bytes + self.device = device + self.ddof = ddof + ndim = array.ndim + self.R = _normalize_axes(axis, ndim) + self.kept = tuple(a for a in range(ndim) if a not in self.R) + self._in_dtype = parse_dtype(array.dtype)[0] + + chunks = array.chunks + if keepdims: + self.shape = tuple(1 if a in self.R else array.shape[a] for a in range(ndim)) + self.chunks = (tuple(1 if a in self.R else chunks[a] for a in range(ndim)) + if chunks else None) + else: + self.shape = tuple(array.shape[a] for a in self.kept) + self.chunks = tuple(chunks[a] for a in self.kept) if chunks else None + + self.dtype = self._infer_dtype() + + def _infer_dtype(self): + sample = np.ones((2,) * self.array.ndim, dtype=self._in_dtype) + if not self.reducer.associative: + res = self.reducer.direct(np, sample, self.R) + else: + state = self.reducer.partial(np, sample, self.R) + res = self.reducer.finalize(np, state, 2 ** len(self.R), self.ddof) + return np.asarray(res).dtype + + def _stream(self, input_slices): + """Reduce over self.R. Associative reducers stream the largest reduced axis in + bounded chunks + combine; non-associative ones read the reduced axis whole.""" + arr = self.array + if not self.reducer.associative: + block = to_device(arr._read_direct(tuple(input_slices)), resolve_device(self.device)) + xp = array_namespace(block) + if block.size == 0: # empty kept region: numpy median/argmin choke -> build empty + empty_shape = tuple(s for i, s in enumerate(block.shape) if i not in self.R) + return xp.empty(empty_shape, dtype=self.dtype) + return self.reducer.direct(xp, block, self.R) + kept_elems = 1 + for a in self.kept: + s = input_slices[a] + kept_elems *= (s.stop - s.start) + chunk_axis = builtins.max(self.R, key=lambda a: arr.shape[a]) + other_reduced = 1 + for a in self.R: + if a != chunk_axis: + other_reduced *= arr.shape[a] + denom = builtins.max(1, kept_elems * other_reduced * self._in_dtype.itemsize) + chunk_len = builtins.max(1, int(self.strip_bytes // denom)) + + size = arr.shape[chunk_axis] + acc = None + for c in range(0, size, chunk_len): + slices = list(input_slices) + slices[chunk_axis] = slice(c, builtins.min(size, c + chunk_len)) + block = to_device(arr._read_direct(tuple(slices)), resolve_device(self.device)) + xp = array_namespace(block) + p = self.reducer.partial(xp, block, self.R) + acc = p if acc is None else self.reducer.combine(xp, acc, p) + count = 1 + for a in self.R: + count *= arr.shape[a] + return self.reducer.finalize(xp, acc, count, self.ddof) + + def read(self, key): + out_ndim = len(self.shape) + if not isinstance(key, tuple): + key = (key,) + key = key + (slice(None),) * (out_ndim - len(key)) + + input_slices = [slice(None)] * self.array.ndim # reduced axes stay full (streamed) + params = [] # (start, stop, step, is_int) per out axis + for oi in range(out_ndim): + k = key[oi] + osize = self.shape[oi] + if _is_int_index(k): + idx = int(k) if k >= 0 else osize + int(k) + start, stop, step, is_int = idx, idx + 1, 1, True + else: + start, stop, step = k.indices(osize) + is_int = False + params.append((start, stop, step, is_int)) + in_axis = oi if self.keepdims else self.kept[oi] + if in_axis in self.kept: + # contiguous read on this kept input axis; step/int applied after reduce + input_slices[in_axis] = slice(start, stop) + # keepdims reduced axis: read stays full; its size-1 output handled in the crop + + block = self._stream(input_slices) # kept-axis order, reduced axes gone + xp = array_namespace(block) + if self.keepdims: + for a in sorted(self.R): + block = xp.expand_dims(block, a) + + crop = tuple(slice(0, stop - start, step) for (start, stop, step, _) in params) + block = block[crop] + for oi in sorted((i for i, p in enumerate(params) if p[3]), reverse=True): + block = xp.squeeze(block, axis=oi) + return block + + +# --------------------------------------------------------------------------- # +# Public ops -- reductions +# --------------------------------------------------------------------------- # + +def reduce(array, reducer, axis=None, keepdims=False, device=None, ddof=0): + """Lazy streaming reduction with a named ``reducer`` (min/max/sum/prod/mean/var/std/ + any/all/argmin/argmax) over ``axis`` (int, tuple, or None for all). ``device`` + (None=inherit, 'cpu', 'cuda') runs it on that device; ``ddof`` applies to var/std.""" + return array._with_transform( + ReduceTransform(array, reducer, axis=axis, keepdims=keepdims, device=device, ddof=ddof)) + + +def min(array, axis=None, keepdims=False, device=None): + """Minimum along axis/axes (lazy, streaming reduction).""" + return reduce(array, "min", axis=axis, keepdims=keepdims, device=device) + + +def max(array, axis=None, keepdims=False, device=None): + """Maximum along axis/axes (lazy, streaming reduction).""" + return reduce(array, "max", axis=axis, keepdims=keepdims, device=device) + + +def sum(array, axis=None, keepdims=False, device=None): + """Sum along axis/axes (lazy, streaming reduction).""" + return reduce(array, "sum", axis=axis, keepdims=keepdims, device=device) + + +def prod(array, axis=None, keepdims=False, device=None): + """Product along axis/axes (lazy, streaming reduction).""" + return reduce(array, "prod", axis=axis, keepdims=keepdims, device=device) + + +def mean(array, axis=None, keepdims=False, device=None): + """Mean along axis/axes (lazy, streaming: running sum / count).""" + return reduce(array, "mean", axis=axis, keepdims=keepdims, device=device) + + +def any(array, axis=None, keepdims=False, device=None): + """Logical OR along axis/axes (lazy, streaming reduction).""" + return reduce(array, "any", axis=axis, keepdims=keepdims, device=device) + + +def all(array, axis=None, keepdims=False, device=None): + """Logical AND along axis/axes (lazy, streaming reduction).""" + return reduce(array, "all", axis=axis, keepdims=keepdims, device=device) + + +def var(array, axis=None, keepdims=False, ddof=0, device=None): + """Variance along axis/axes (lazy, streaming: (sum, sum-of-squares, count), float64).""" + return reduce(array, "var", axis=axis, keepdims=keepdims, device=device, ddof=ddof) + + +def std(array, axis=None, keepdims=False, ddof=0, device=None): + """Standard deviation along axis/axes (lazy, streaming; sqrt of var).""" + return reduce(array, "std", axis=axis, keepdims=keepdims, device=device, ddof=ddof) + + +def argmin(array, axis=None, keepdims=False, device=None): + """Index of the minimum along a single ``axis`` (or flat if None). Reads the reduced + axis whole per kept-tile (not associatively streamable).""" + return reduce(array, "argmin", axis=axis, keepdims=keepdims, device=device) + + +def argmax(array, axis=None, keepdims=False, device=None): + """Index of the maximum along a single ``axis`` (or flat if None). Reads the reduced + axis whole per kept-tile (not associatively streamable).""" + return reduce(array, "argmax", axis=axis, keepdims=keepdims, device=device) + + +def median(array, axis=None, keepdims=False, device=None): + """Median along axis/axes (or all if None). Not associatively streamable, so it reads + the reduced axis/axes whole per kept-tile (memory bounded by the kept region).""" + return reduce(array, "median", axis=axis, keepdims=keepdims, device=device) + + +def _iter_region_slices(shape, itemsize, budget): + """Yield memory-bounded region slice-tuples covering ``shape`` (<= ``budget`` bytes + each), expanding trailing (contiguous) axes first.""" + import itertools + budget_elems = builtins.max(1, int(budget) // builtins.max(1, itemsize)) + region = [1] * len(shape) + acc = 1 + for a in reversed(range(len(shape))): + region[a] = builtins.min(shape[a], builtins.max(1, budget_elems // acc)) + acc *= region[a] + if region[a] < shape[a]: + break + for start in itertools.product(*[range(0, s, r) for s, r in zip(shape, region)]): + yield tuple(slice(st, builtins.min(st + r, s)) + for st, r, s in zip(start, region, shape)) + + +def histogram(array, bins=256, range=None, strip_bytes=_DEFAULT_STRIP_BYTES, device=None): + """Streaming histogram over the whole array -- the substrate for global thresholds. + + A histogram is an associative, memory-bound reduction: it equals the sum of per-region + histograms sharing the same bins, and the output size is fixed (``bins``) regardless of + array size. Returns ``(counts, bin_edges)`` like ``numpy.histogram``, and matches it + exactly. ``bins`` may be an int (with an optional ``range`` (lo, hi); if omitted, the + data min/max are found in one streaming pass) or a precomputed edges array. For a + per-channel/plane histogram, slice first: ``histogram(da[channel])``. ``device`` + (None=inherit, 'cpu', 'cuda') runs the accumulation on that device; the result is host. + """ + from ..utils import parse_dtype + dev = resolve_device(device) + itemsize = parse_dtype(array.dtype)[0].itemsize + bins_is_edges = not np.isscalar(bins) + if not bins_is_edges and range is None: + lo = float(min(array, device=device).compute()) # streaming min/max (memory-bound) + hi = float(max(array, device=device).compute()) + if not (np.isfinite(lo) and np.isfinite(hi)) or lo == hi: + hi = lo + 1.0 + range = (lo, hi) + if range is not None: + range = (float(range[0]), float(range[1])) + + counts = None + edges = None + for region in _iter_region_slices(array.shape, itemsize, strip_bytes): + block = to_device(array._read_direct(region), dev) + xp = array_namespace(block) + if bins_is_edges: + c, edges = xp.histogram(block, bins=xp.asarray(bins)) + else: + c, edges = xp.histogram(block, bins=bins, range=range) + counts = c if counts is None else counts + c + if counts is None: # empty array + edges = np.asarray(bins, dtype=float) if bins_is_edges else \ + np.linspace(range[0], range[1], int(bins) + 1) + counts = np.zeros(len(edges) - 1, dtype=np.int64) + # bring back to host (counts/edges may be cupy) and return like numpy.histogram + return asnumpy(counts).astype(np.int64), asnumpy(edges) + + +def unique(array, strip_bytes=_DEFAULT_STRIP_BYTES, device=None): + """Streaming distinct values over the whole array (like ``numpy.unique``: a sorted 1-D + array of the distinct values). Memory-bounded by the running set of distinct values plus + one region, so it is cheap when there are few distinct values (e.g. a label image) and + grows with that count otherwise. Eager, like ``histogram``: returns a host numpy array. + """ + from ..utils import parse_dtype + dev = resolve_device(device) + dt = parse_dtype(array.dtype)[0] + if 0 in tuple(array.shape): # empty array -> no distinct values + return np.array([], dtype=dt) + acc = None + for region in _iter_region_slices(array.shape, dt.itemsize, strip_bytes): + block = to_device(array._read_direct(region), dev) + xp = array_namespace(block) + u = xp.unique(block) + acc = u if acc is None else xp.unique(xp.concatenate([acc, u])) + if acc is None: # empty array + return np.array([], dtype=dt) + return asnumpy(acc) + + +__all__ = [ + "ReduceTransform", "reduce", + "min", "max", "sum", "prod", "mean", "any", "all", + "var", "std", "argmin", "argmax", "median", "histogram", "unique", +] diff --git a/src/dyna_zarr/operations/scan.py b/src/dyna_zarr/operations/scan.py new file mode 100644 index 0000000..07ac529 --- /dev/null +++ b/src/dyna_zarr/operations/scan.py @@ -0,0 +1,205 @@ +"""Prefix-scan ops along a single axis: cumsum / cumprod / cummax / cummin. + +A scan is neither pointwise nor a reduction: the output at index ``p`` along the scan axis +depends on ALL input up to ``p``. Two execution paths, with different memory behavior: + +- ``io.write`` routes to ``scan_write`` (below), which streams with a BOUNDED CARRY: tile the + cross-section, walk the scan axis in strips keeping a running accumulator. Peak memory is + ~ one strip x one cross-section tile, independent of the scan axis and the array size -- + FULLY memory-bounded, read-once/write-once. This is the path that matters. +- The lazy ``read(key)`` (for ``compute`` / sub-slices) can't carry state between independent + reads, so it reads the input PREFIX ``[0, stop)`` along the scan axis, accumulates, and + slices back. That is correct and chunk-invariant, but bounded only by the prefix (~ the + scan axis), so prefer ``io.write`` for large scans. +""" + +from __future__ import annotations + +from itertools import product as _iproduct + +import numpy as np + +from ._base import Transform +from ._backend import array_namespace, asnumpy, resolve_device, to_device +from .structural import _norm_key + +_DEFAULT_SCAN_MEM = 256 * 1024 * 1024 # per-call streaming budget (bytes) + + +def _cumsum(xp, b, ax): + return xp.cumsum(b, axis=ax) + + +def _cumprod(xp, b, ax): + return xp.cumprod(b, axis=ax) + + +def _accumulate_via(ufunc_name): + """cummax/cummin via ``ufunc.accumulate``; falls back to host numpy if the array + namespace (e.g. some cupy versions) does not implement ufunc.accumulate.""" + def acc(xp, b, ax): + uf = getattr(xp, ufunc_name) + try: + return uf.accumulate(b, axis=ax) + except (AttributeError, TypeError, NotImplementedError): + host = np.asarray(asnumpy(b)) + return xp.asarray(getattr(np, ufunc_name).accumulate(host, axis=ax)) + return acc + + +_ACC = { + "cumsum": _cumsum, + "cumprod": _cumprod, + "cummax": _accumulate_via("maximum"), + "cummin": _accumulate_via("minimum"), +} + + +class ScanTransform(Transform): + """Lazy prefix scan along one axis. See the module docstring.""" + + def __init__(self, array, op, axis, device=None, name=None): + super().__init__() + self.array = array + self.op = op + self.acc = _ACC[op] + self.axis = int(axis) if axis >= 0 else array.ndim + int(axis) + if not (0 <= self.axis < array.ndim): + raise ValueError(f"scan axis {axis} out of range for ndim {array.ndim}") + self.device = device + self.name = name or op + self.shape = tuple(array.shape) + # dtype as numpy's accumulate would produce for this input dtype (e.g. cumsum keeps + # the integer dtype, matching numpy/dask -- users can astype to widen if needed). + self.dtype = np.asarray(self.acc(np, np.zeros((1,), dtype=array.dtype), 0)).dtype + self.chunks = None + + def read(self, key): + norm = _norm_key(key, self.shape) # per axis (is_int, start, stop, step) + a = self.axis + ndim = len(self.shape) + + # Read the full prefix on the scan axis; keep every axis (size-1 for int indices) so + # the scan axis stays at position ``a`` with no dim-shift bookkeeping. + read_key = [] + for d in range(ndim): + is_int, start, stop, step = norm[d] + if d == a: + read_key.append(slice(0, stop)) # prefix [0, stop), step 1 + elif is_int: + read_key.append(slice(start, start + 1)) # keepdim; squeezed at the end + else: + read_key.append(slice(start, stop, step)) + + dev = resolve_device(self.device) + block = to_device(self.array._read_direct(tuple(read_key)), dev) + xp = array_namespace(block) + acc = self.acc(xp, block, a) + + # Select the requested output range on the scan axis (within the prefix), then drop + # every axis that was an integer index in the original key. + is_int_a, start_a, stop_a, step_a = norm[a] + sel = [slice(None)] * ndim + sel[a] = slice(start_a, start_a + 1) if is_int_a else slice(start_a, stop_a, step_a) + acc = acc[tuple(sel)] + drop = tuple(d for d in range(ndim) if norm[d][0]) + return xp.squeeze(acc, axis=drop) if drop else acc + + +# op -> (local scan, cross-strip combine with the running carry). carry is size-1 on the scan +# axis and broadcasts over the strip; this makes the scan a bounded-carry stream. +_COMBINE = { + "cumsum": lambda xp, carry, local: carry + local, + "cumprod": lambda xp, carry, local: carry * local, + "cummax": lambda xp, carry, local: xp.maximum(carry, local), + "cummin": lambda xp, carry, local: xp.minimum(carry, local), +} + + +def scan_write(source, op, axis, output_path, output_chunks=None, + max_mem=_DEFAULT_SCAN_MEM, dtype=None, zarr_format=2): + """Stream a prefix scan of ``source`` along ``axis`` to ``output_path``, FULLY + memory-bounded. A scan has a bounded carry (a single slab perpendicular to the scan + axis), so we tile the cross-section and, within each tile, walk the scan axis in strips + keeping a running carry: ``out_strip = combine(carry, local_scan(strip))``, then + ``carry = last slab of out_strip``. Peak memory is ~ one strip x one cross-section tile, + independent of the scan axis and of the array size. Read-once / write-once. + """ + import zarr + from ..utils import parse_dtype + + shape = tuple(int(s) for s in source.shape) + ndim = len(shape) + a = int(axis) if axis >= 0 else ndim + int(axis) + accfun = _ACC[op] + combine = _COMBINE[op] + dt = parse_dtype(dtype if dtype is not None else source.dtype)[0] + budget = max(1, int(max_mem) // dt.itemsize) + + oc = tuple(output_chunks) if output_chunks is not None else ( + tuple(source.chunks) if source.chunks else tuple(min(s, 256) for s in shape)) + out = zarr.open(str(output_path), mode="w", shape=shape, chunks=oc, + dtype=dt, zarr_format=zarr_format) + + # cross-section tile (all axes but the scan axis) sized to <= budget/2 so the carry fits + # and a strip of >= 2 slabs also fits; trailing axes grow first for contiguity. + cross_budget = max(1, budget // 2) + tile = [1] * ndim + acc = 1 + for d in reversed([d for d in range(ndim) if d != a]): + tile[d] = min(shape[d], max(1, cross_budget // acc)) + acc *= tile[d] + if tile[d] < shape[d]: + break + cross_size = 1 + for d in range(ndim): + if d != a: + cross_size *= tile[d] + strip_a = max(1, budget // max(1, cross_size)) # slabs of the scan axis per read + + cross_ranges = [range(0, shape[d], tile[d]) for d in range(ndim) if d != a] + other_axes = [d for d in range(ndim) if d != a] + for cross_origin in _iproduct(*cross_ranges): + base = [None] * ndim + for d, o in zip(other_axes, cross_origin): + base[d] = slice(o, min(o + tile[d], shape[d])) + carry = None + for a0 in range(0, shape[a], strip_a): + sl = list(base) + sl[a] = slice(a0, min(a0 + strip_a, shape[a])) + sl = tuple(sl) + block = np.asarray(asnumpy(source._read_direct(sl))) + local = accfun(np, block, a) + out_block = local if carry is None else combine(np, carry, local) + out[sl] = out_block + last = [slice(None)] * ndim + last[a] = slice(-1, None) # keepdims last slab -> next carry + carry = out_block[tuple(last)] + return output_path + + +def _scan(array, op, axis, device=None): + return array._with_transform(ScanTransform(array, op, axis, device=device)) + + +def cumsum(array, axis, device=None): + """Cumulative sum along ``axis`` (shape-preserving).""" + return _scan(array, "cumsum", axis, device=device) + + +def cumprod(array, axis, device=None): + """Cumulative product along ``axis`` (shape-preserving).""" + return _scan(array, "cumprod", axis, device=device) + + +def cummax(array, axis, device=None): + """Cumulative maximum along ``axis`` (shape-preserving).""" + return _scan(array, "cummax", axis, device=device) + + +def cummin(array, axis, device=None): + """Cumulative minimum along ``axis`` (shape-preserving).""" + return _scan(array, "cummin", axis, device=device) + + +__all__ = ["ScanTransform", "cumsum", "cumprod", "cummax", "cummin"] diff --git a/src/dyna_zarr/operations.py b/src/dyna_zarr/operations/structural.py similarity index 53% rename from src/dyna_zarr/operations.py rename to src/dyna_zarr/operations/structural.py index deb6436..bfee382 100644 --- a/src/dyna_zarr/operations.py +++ b/src/dyna_zarr/operations/structural.py @@ -1,11 +1,5 @@ -""" -Array operations and transformations for DynamicArray. - -This module provides: -- Transform classes for lazy operations -- operations class with static methods for array manipulations -- Support for all major numpy/dask.array operations -""" +"""Structural / coordinate transforms: concat, stack, slice, transpose, reshape, +squeeze, flatten, pad, tile, roll, flip, expand_dims, swap_axes (+ slice_array).""" import numpy as np from typing import Tuple, Union, List, Optional, Any, TYPE_CHECKING @@ -14,18 +8,70 @@ from dyna_zarr.dynamic_array import DynamicArray -class Transform: - """ - Base class for lazy transformations. - """ +from ._base import Transform, _is_int_index, _perm_on_surviving +from ._backend import array_namespace - def __init__(self): - self.shape = None - self.chunks = None - self.dtype = None - def read(self, key): - raise NotImplementedError +def _reshape_read(array, new_shape, key): + """Windowed read for reshape/flatten (a C-order flat re-index). + + An output region's elements occupy flat indices [fmin, fmax] (same in input and output + C-order). We read only the covering rectangle of input rows spanning that flat range, + then gather the region's flat positions from it. Memory ~= region + one inner slab, so + contiguous reads (io.write / compute) are memory-bound; only scatter patterns (a stepped + output) can widen the flat span toward the whole array. + """ + axinfo = _norm_key(key, new_shape) + nnd = len(new_shape) + nstride = [1] * nnd + for a in range(nnd - 2, -1, -1): + nstride[a] = nstride[a + 1] * new_shape[a + 1] + + kept_axes = [a for a, (is_int, *_r) in enumerate(axinfo) if not is_int] + kept_shape = tuple(len(range(axinfo[a][1], axinfo[a][2], axinfo[a][3])) for a in kept_axes) + flat = np.zeros(kept_shape, dtype=np.int64) # nd flat-index of each output elem + for a, (is_int, start, stop, step) in enumerate(axinfo): + st = int(nstride[a]) + if is_int: + flat = flat + start * st + else: + coord = np.arange(start, stop, step, dtype=np.int64) * st + shp = [1] * len(kept_shape) + shp[kept_axes.index(a)] = coord.shape[0] + flat = flat + coord.reshape(shp) + + in_shape = array.shape + in_stride0 = 1 + for s in in_shape[1:]: + in_stride0 *= s + if flat.size == 0: + block = array._read_direct((slice(0, 0),) + (slice(None),) * (len(in_shape) - 1)) + return array_namespace(block).reshape(block, kept_shape) + fmin, fmax = int(flat.min()), int(flat.max()) + c0, c1 = fmin // in_stride0, fmax // in_stride0 + box = array._read_direct((slice(c0, c1 + 1),) + (slice(None),) * (len(in_shape) - 1)) + xp = array_namespace(box) + flatbox = box.reshape(-1) + idx = xp.asarray(flat - c0 * in_stride0) # into flatbox, on the box's device + return flatbox[idx] + + +def _norm_key(key, shape): + """Per output axis -> (is_int, start, stop, step) with concrete non-negative values. + Integer indices become (True, idx, idx+1, 1); the caller squeezes those axes.""" + if not isinstance(key, tuple): + key = (key,) + key = key + (slice(None),) * (len(shape) - len(key)) + out = [] + for a, k in enumerate(key): + size = shape[a] + if _is_int_index(k): + idx = int(k) if k >= 0 else size + int(k) + out.append((True, idx, idx + 1, 1)) + else: + start, stop, step = k.indices(size) + out.append((False, start, stop, step)) + return out class ConcatenateTransform(Transform): @@ -87,12 +133,12 @@ def read(self, key): raise NotImplementedError(f"Indexing with {type(k)} not supported") axis_slice = normalized_key[self.axis] - start = axis_slice.start if axis_slice.start is not None else 0 - stop = axis_slice.stop if axis_slice.stop is not None else self.shape[self.axis] - step = axis_slice.step if axis_slice.step is not None else 1 + start, stop, step = axis_slice.indices(self.shape[self.axis]) - if step != 1: - raise NotImplementedError("Step slicing not supported yet") + if step < 0: + raise NotImplementedError("Negative-step slicing on the concat axis not supported yet") + # Route the contiguous span [start, stop); the step is re-applied to the + # assembled result below so we read each source array only once. # Find which arrays we need to read from arrays_to_read = [] @@ -116,7 +162,12 @@ def read(self, key): # OPTIMIZATION: Handle based on number of arrays if len(arrays_to_read) == 0: - raise ValueError("No data to read") + # Empty selection on the concat axis: return a correctly-shaped empty array + # (reading the first source with a zero-length concat-axis slice fixes the + # other axis sizes and the dtype without materialising any data). + empty_key = list(normalized_key) + empty_key[self.axis] = slice(0, 0) + result = self.arrays[0]._read_direct(tuple(empty_key)) elif len(arrays_to_read) == 1: # Single array - no concatenation needed result = arrays_to_read[0][0]._read_direct(arrays_to_read[0][1]) @@ -131,8 +182,13 @@ def read(self, key): result = np.concatenate(result_parts, axis=self.axis) + # Re-apply the step on the concat axis (routing above read the contiguous span). + if step != 1: + step_idx = (slice(None),) * self.axis + (slice(None, None, step),) + result = result[step_idx] + # Remove dimensions that were indexed with int - squeeze_axes = [i for i, k in enumerate(key) if isinstance(k, int)] + squeeze_axes = [i for i, k in enumerate(key) if _is_int_index(k)] for ax in reversed(squeeze_axes): result = np.squeeze(result, axis=ax) @@ -173,16 +229,23 @@ def read(self, key): source_key = key[:self.axis] + key[self.axis + 1:] # Determine which arrays to read - if isinstance(new_axis_key, int): - # Single array + if _is_int_index(new_axis_key): + # Single array - the new axis is dropped return self.arrays[new_axis_key]._read_direct(source_key) - elif isinstance(new_axis_key, slice): - start = new_axis_key.start if new_axis_key.start is not None else 0 - stop = new_axis_key.stop if new_axis_key.stop is not None else len(self.arrays) - step = new_axis_key.step if new_axis_key.step is not None else 1 - parts = [self.arrays[i]._read_direct(source_key) for i in range(start, stop, step)] - return np.stack(parts, axis=self.axis) + # Slice on the new axis. Insert the stacked axis at its position among the + # *surviving* source axes (earlier integer indices dropped their axes). + idxs = range(*new_axis_key.indices(len(self.arrays))) + insert_pos = sum(1 for j in range(self.axis) if not _is_int_index(key[j])) + + if len(idxs) == 0: + # Empty selection: build an empty array of the correct per-part shape. + sample = self.arrays[0]._read_direct(source_key) + empty_shape = sample.shape[:insert_pos] + (0,) + sample.shape[insert_pos:] + return np.empty(empty_shape, dtype=sample.dtype) + + parts = [self.arrays[i]._read_direct(source_key) for i in idxs] + return np.stack(parts, axis=insert_pos) class SliceTransform(Transform): @@ -193,58 +256,51 @@ class SliceTransform(Transform): def __init__(self, array: 'DynamicArray', key): super().__init__() self.array = array - + # Normalize key to tuple if not isinstance(key, tuple): key = (key,) - - self.key = key - # Compute output shape and track new axes + chunks = array.chunks if array.chunks is not None else (1,) * array.ndim new_shape = [] new_chunks = [] - original_dim = 0 - chunks = array.chunks if array.chunks is not None else (1,) * array.ndim - - # Track which original dimensions are being kept - kept_dims = [] + normalized = [] # store CONCRETE, non-negative keys so shape/read math is + original_dim = 0 # simple (negatives + None resolved once, here) for k in key: if k is np.newaxis: - # Add new axis with chunk size 1 + normalized.append(k) new_shape.append(1) new_chunks.append(1) + continue + if original_dim >= array.ndim: + raise IndexError("Too many indices for array") + size = array.shape[original_dim] + if isinstance(k, (int, np.integer)): + ki = int(k) if k >= 0 else size + int(k) # normalize negative index + if not (0 <= ki < size): + raise IndexError( + f"index {k} out of bounds for axis {original_dim} of size {size}") + normalized.append(ki) # int removes this dimension + elif isinstance(k, slice): + s = slice(*k.indices(size)) # resolves None + negatives + normalized.append(s) + new_shape.append(len(range(s.start, s.stop, s.step))) + new_chunks.append(chunks[original_dim]) else: - if original_dim >= array.ndim: - raise IndexError("Too many indices for array") - - if isinstance(k, int): - # This dimension will be removed - pass - elif isinstance(k, slice): - # Calculate size for this dimension - size = array.shape[original_dim] - start = k.start if k.start is not None else 0 - stop = k.stop if k.stop is not None else size - step = k.step if k.step is not None else 1 - new_shape.append((stop - start + step - 1) // step) - new_chunks.append(chunks[original_dim]) - kept_dims.append(original_dim) - else: - raise TypeError(f"Invalid index type: {type(k)}") - - original_dim += 1 + raise TypeError(f"Invalid index type: {type(k)}") + original_dim += 1 - # Add remaining dimensions + # Add remaining (untouched) dimensions for i in range(original_dim, array.ndim): new_shape.append(array.shape[i]) new_chunks.append(chunks[i]) - kept_dims.append(i) + self.key = tuple(normalized) self.shape = tuple(new_shape) self.chunks = tuple(new_chunks) self.dtype = array.dtype - self.new_axes = [i for i, k in enumerate(key) if k is np.newaxis] + self.new_axes = [i for i, k in enumerate(self.key) if k is np.newaxis] def read(self, read_key): """ @@ -279,11 +335,19 @@ def read(self, read_key): # Dimension was sliced in the stored slice read_elem = read_key[output_dim] - # Get parameters of the stored slice + # Normalize the incoming read element (resolve None + negatives) against + # this output axis, so the composition below sees concrete non-negatives. + osize = self.shape[output_dim] + if isinstance(read_elem, (int, np.integer)): + read_elem = int(read_elem) if read_elem >= 0 else osize + int(read_elem) + elif isinstance(read_elem, slice): + read_elem = slice(*read_elem.indices(osize)) + + # Get parameters of the (already concrete) stored slice orig_size = self.array.shape[input_dim] - stored_start = stored_key_elem.start if stored_key_elem.start is not None else 0 - stored_stop = stored_key_elem.stop if stored_key_elem.stop is not None else orig_size - stored_step = stored_key_elem.step if stored_key_elem.step is not None else 1 + stored_start = stored_key_elem.start + stored_stop = stored_key_elem.stop + stored_step = stored_key_elem.step if isinstance(read_elem, (int, np.integer)): # Compose integer index with slice @@ -319,10 +383,17 @@ def read(self, read_key): else: raise TypeError(f"Invalid stored key type: {type(stored_key_elem)}") - # Add any remaining dimensions that weren't in the stored key + # Add any remaining dimensions that weren't in the stored key (normalize the + # read element against the underlying axis so no negatives reach the base array) while input_dim < self.array.ndim: if output_dim < len(read_key): - full_key.append(read_key[output_dim]) + elem = read_key[output_dim] + isize = self.array.shape[input_dim] + if isinstance(elem, (int, np.integer)): + elem = int(elem) if elem >= 0 else isize + int(elem) + elif isinstance(elem, slice): + elem = slice(*elem.indices(isize)) + full_key.append(elem) output_dim += 1 else: full_key.append(slice(None)) @@ -371,18 +442,24 @@ def read(self, key): # Pad key with full slices if needed key = key + (slice(None),) * (len(self.shape) - len(key)) - - # Build the key for the underlying array by removing the element at self.axis - # since the underlying array doesn't have this dimension yet + + # Element that applies to the inserted (singleton) axis, and the key for the + # underlying array (which lacks that axis). + key_ins = key[self.axis] underlying_key = key[:self.axis] + key[self.axis + 1:] - + # Read from underlying array result = self.array._read_direct(underlying_key) - - # Add back the singleton dimension at the correct axis - result = np.expand_dims(result, axis=self.axis) - - return result + + # Insert the singleton axis at its position among the *surviving* axes: earlier + # axes indexed by an integer were dropped by the read, shifting the position. + insert_pos = sum(1 for j in range(self.axis) if not _is_int_index(key[j])) + result = np.expand_dims(result, axis=insert_pos) + + # Apply the key element to the inserted axis (int drops it, slice sizes it 0/1). + idx = (slice(None),) * insert_pos + (key_ins,) + \ + (slice(None),) * (result.ndim - insert_pos - 1) + return result[idx] class SwapAxesTransform(Transform): @@ -424,11 +501,12 @@ def read(self, key): # Read from underlying array with unswapped key result = self.array._read_direct(tuple(original_key)) - - # Swap the axes in the result back - result = np.swapaxes(result, self.axis1, self.axis2) - - return result + + # A swap is a permutation; apply it on the surviving axes so integer indices + # that dropped an axis don't leave np.swapaxes with a stale axis index. + axes = list(range(self.array.ndim)) + axes[self.axis1], axes[self.axis2] = axes[self.axis2], axes[self.axis1] + return _perm_on_surviving(result, tuple(key), tuple(axes)) class TransposeTransform(Transform): @@ -475,10 +553,9 @@ def read(self, key): # Read from underlying array with reordered key result = self.array._read_direct(reordered_key) - # Now transpose the result to match the expected output order - result = np.transpose(result, self.axes) - - return result + # Transpose to output order, honouring any integer indices that dropped an + # axis (a plain np.transpose(result, self.axes) would use stale axis indices). + return _perm_on_surviving(result, key, self.axes) # Extended operations from extended_operations.py @@ -500,9 +577,7 @@ def __init__(self, array: 'DynamicArray', shape: Tuple[int, ...]): self.dtype = array.dtype def read(self, key): - # For reshape, we need to materialize and reshape - data = self.array._read_direct(slice(None)) - return data.reshape(self.new_shape)[key] + return _reshape_read(self.array, self.new_shape, key) class SqueezeTransform(Transform): @@ -562,17 +637,15 @@ def read(self, key): if not isinstance(key, tuple): key = (key,) - # Build unsqueezed key by inserting slice(None) for squeezed axes - # Track which positions in unsqueezed_key correspond to squeezed axes + # Build unsqueezed key by inserting slice(None) for squeezed axes. unsqueezed_key = [] - axes_to_squeeze_in_result = [] + squeezed_input_axes = [] output_idx = 0 for input_idx in range(self.array.ndim): if input_idx in self.squeeze_axes: - # This axis was squeezed - insert full slice + # This axis was squeezed - insert full slice (it is size 1 upstream). unsqueezed_key.append(slice(None)) - # Mark this position for squeezing in the result - axes_to_squeeze_in_result.append(len(unsqueezed_key) - 1) + squeezed_input_axes.append(input_idx) else: # This axis is preserved - use key from read operation if output_idx < len(key): @@ -580,17 +653,20 @@ def read(self, key): else: unsqueezed_key.append(slice(None)) output_idx += 1 - + # Read only the required region from underlying array result = self.array._read_direct(tuple(unsqueezed_key)) - - - # Squeeze the marked axes (in reverse order to avoid index shifting) - for ax in sorted(axes_to_squeeze_in_result, reverse=True): - if ax < len(result.shape) and result.shape[ax] == 1: - result = np.squeeze(result, axis=ax) - - + + # A squeezed axis lands in the result at the position given by the number of + # *surviving* (non-integer-indexed) axes before it - integer indices earlier in + # the key drop their axes and shift everything left. + to_remove = [ + sum(1 for j in range(input_idx) if not _is_int_index(unsqueezed_key[j])) + for input_idx in squeezed_input_axes + ] + for ax in sorted(to_remove, reverse=True): + result = np.squeeze(result, axis=ax) + return result @@ -606,7 +682,7 @@ def __init__(self, array: 'DynamicArray'): self.dtype = array.dtype def read(self, key): - return self.array._read_direct(slice(None)).flatten()[key] + return _reshape_read(self.array, self.shape, key) class PadTransform(Transform): @@ -632,8 +708,28 @@ def __init__(self, array: 'DynamicArray', pad_width: Union[int, Tuple]): self.dtype = array.dtype def read(self, key): - data = self.array._read_direct(slice(None)) - return np.pad(data, self.pad_width)[key] + # Memory-bound: read only the core-overlapping input region, then pad just the + # border that this region includes (constant 0, like np.pad's default). + input_slices, pad_widths, crop, squeeze_axes = [], [], [], [] + for a, (is_int, start, stop, step) in enumerate(_norm_key(key, self.shape)): + before, _after = self.pad_width[a] + in_size = self.array.shape[a] + core_lo, core_hi = max(start, before), min(stop, before + in_size) + input_slices.append(slice(core_lo - before, core_hi - before) + if core_hi > core_lo else slice(0, 0)) + pad_before = max(0, min(stop, before) - start) + pad_after = max(0, stop - max(start, before + in_size)) + pad_widths.append((pad_before, pad_after)) + crop.append(slice(0, stop - start, step)) + if is_int: + squeeze_axes.append(a) + core = self.array._read_direct(tuple(input_slices)) + xp = array_namespace(core) + out = xp.pad(core, pad_widths) # constant 0 + out = out[tuple(crop)] + for a in sorted(squeeze_axes, reverse=True): + out = xp.squeeze(out, axis=a) + return out class TileTransform(Transform): @@ -653,8 +749,20 @@ def __init__(self, array: 'DynamicArray', reps: Union[int, Tuple]): self.dtype = array.dtype def read(self, key): - data = self.array._read_direct(slice(None)) - return np.tile(data, self.reps)[key] + # Bounded by the INPUT (the tile), not the full tiled OUTPUT: read the tile once, + # then gather the region via modulo indexing (out[i] = in[i % N] per axis). + axinfo = _norm_key(key, self.shape) + data = self.array._read_direct(tuple(slice(None) for _ in range(self.array.ndim))) + xp = array_namespace(data) + idx, squeeze_axes = [], [] + for a, (is_int, start, stop, step) in enumerate(axinfo): + idx.append(xp.arange(start, stop, step) % self.array.shape[a]) + if is_int: + squeeze_axes.append(a) + result = data[xp.ix_(*idx)] + for a in sorted(squeeze_axes, reverse=True): + result = xp.squeeze(result, axis=a) + return result class RollTransform(Transform): @@ -670,8 +778,36 @@ def __init__(self, array: 'DynamicArray', shift: int, axis: Optional[int] = None self.dtype = array.dtype def read(self, key): - data = self.array._read_direct(slice(None)) - return np.roll(data, self.shift, axis=self.axis)[key] + if self.axis is None: + # flatten-roll: genuinely global. Rare; keep the whole-input fallback. + data = self.array._read_direct(tuple(slice(None) for _ in range(self.array.ndim))) + return np.roll(data, self.shift)[key] + # Memory-bound: out[i] = in[(i-shift) % N] along the axis, so a contiguous output + # run maps to 1 or 2 wrapped input segments. Read those, other axes contiguous, + # then crop step + squeeze ints. + A = self.axis if self.axis >= 0 else self.array.ndim + self.axis + N = self.array.shape[A] + shift = (self.shift % N) if N else 0 + axinfo = _norm_key(key, self.shape) + base = [slice(start, stop) for (_ii, start, stop, _st) in axinfo] + _iiA, startA, stopA, _stA = axinfo[A] + L = stopA - startA + s0 = (startA - shift) % N if N else 0 + if N == 0 or s0 + L <= N: + base[A] = slice(s0, s0 + L) + block = self.array._read_direct(tuple(base)) + else: + k1, k2 = list(base), list(base) + k1[A] = slice(s0, N) + k2[A] = slice(0, s0 + L - N) + b1 = self.array._read_direct(tuple(k1)) + b2 = self.array._read_direct(tuple(k2)) + block = array_namespace(b1).concatenate([b1, b2], axis=A) + xp = array_namespace(block) + block = block[tuple(slice(0, stop - start, step) for (_ii, start, stop, step) in axinfo)] + for a in sorted((i for i, (ii, *_r) in enumerate(axinfo) if ii), reverse=True): + block = xp.squeeze(block, axis=a) + return block class FlipTransform(Transform): @@ -686,348 +822,138 @@ def __init__(self, array: 'DynamicArray', axis: int): self.dtype = array.dtype def read(self, key): - data = self.array._read_direct(slice(None)) - return np.flip(data, axis=self.axis)[key] - + # Memory-bound: read only the mirrored input window (positive-step slice, since + # zarr/tensorstore reject negative steps), flip it in-memory, then crop step + + # squeeze ints. out[start:stop) along the axis mirrors input[N-stop:N-start). + A = self.axis if self.axis >= 0 else self.array.ndim + self.axis + axinfo = _norm_key(key, self.shape) + input_slices = [] + for a, (_is_int, start, stop, step) in enumerate(axinfo): + if a == A: + N = self.array.shape[a] + input_slices.append(slice(N - stop, N - start)) + else: + input_slices.append(slice(start, stop)) + block = self.array._read_direct(tuple(input_slices)) + xp = array_namespace(block) + block = xp.flip(block, axis=A) + block = block[tuple(slice(0, stop - start, step) for (_ii, start, stop, step) in axinfo)] + for a in sorted((i for i, (ii, *_r) in enumerate(axinfo) if ii), reverse=True): + block = xp.squeeze(block, axis=a) + return block -class ClipTransform(Transform): - """Clip array values to a range.""" - - def __init__(self, array: 'DynamicArray', a_min: Optional[float], a_max: Optional[float]): - super().__init__() - self.array = array - self.a_min = a_min - self.a_max = a_max - self.shape = array.shape - self.chunks = array.chunks - self.dtype = array.dtype - - def read(self, key): - data = self.array._read_direct(key) - return np.clip(data, self.a_min, self.a_max) -class AbsTransform(Transform): - """Absolute value.""" - - def __init__(self, array: 'DynamicArray'): - super().__init__() - self.array = array - self.shape = array.shape - self.chunks = array.chunks - self.dtype = array.dtype - - def read(self, key): - return np.abs(self.array._read_direct(key)) +def slice_array(array: 'DynamicArray', key) -> 'DynamicArray': + """Create a lazy slice of an array.""" + transform = SliceTransform(array, key) + return array._with_transform(transform) -class SignTransform(Transform): - """Sign of array elements.""" - - def __init__(self, array: 'DynamicArray'): - super().__init__() - self.array = array - self.shape = array.shape - self.chunks = array.chunks - self.dtype = array.dtype - - def read(self, key): - return np.sign(self.array._read_direct(key)) +# --------------------------------------------------------------------------- # +# Public ops -- structural / coordinate (this module owns both transforms + ops) +# --------------------------------------------------------------------------- # +def expand_dims(array, axis): + """Add a new axis of length 1.""" + return array._with_transform(ExpandDimsTransform(array, axis)) -class RoundTransform(Transform): - """Round array elements.""" - - def __init__(self, array: 'DynamicArray', decimals: int = 0): - super().__init__() - self.array = array - self.decimals = decimals - self.shape = array.shape - self.chunks = array.chunks - self.dtype = array.dtype - - def read(self, key): - return np.round(self.array._read_direct(key), decimals=self.decimals) +def concatenate(arrays, axis=0): + """Concatenate arrays along an existing axis.""" + if not arrays: + raise ValueError("Need at least one array to concatenate") + return arrays[0]._with_transform(ConcatenateTransform(tuple(arrays), axis)) -class SqrtTransform(Transform): - """Square root.""" - - def __init__(self, array: 'DynamicArray'): - super().__init__() - self.array = array - self.shape = array.shape - self.chunks = array.chunks - self.dtype = np.float64 - - def read(self, key): - return np.sqrt(self.array._read_direct(key)) +def stack(arrays, axis=0): + """Stack arrays along a new axis.""" + if not arrays: + raise ValueError("Need at least one array to stack") + return arrays[0]._with_transform(StackTransform(tuple(arrays), axis)) -class WhereTransform(Transform): - """Conditional element selection.""" - - def __init__(self, condition: 'DynamicArray', x: 'DynamicArray', y: 'DynamicArray'): - super().__init__() - if not (condition.shape == x.shape == y.shape): - raise ValueError("All arrays must have the same shape") - - self.condition = condition - self.x = x - self.y = y - self.shape = x.shape - self.chunks = x.chunks - self.dtype = x.dtype - - def read(self, key): - cond = self.condition._read_direct(key) - x_data = self.x._read_direct(key) - y_data = self.y._read_direct(key) - return np.where(cond, x_data, y_data) +def swap_axes(array, axis1, axis2): + """Swap two axes.""" + return array._with_transform(SwapAxesTransform(array, axis1, axis2)) -class MultiplyTransform(Transform): - """Element-wise multiplication.""" - - def __init__(self, array1: 'DynamicArray', array2: Union['DynamicArray', float]): - super().__init__() - self.array1 = array1 - self.array2 = array2 - self.shape = array1.shape - self.chunks = array1.chunks - self.dtype = array1.dtype - - def read(self, key): - # Import here to avoid circular dependency - from dyna_zarr.dynamic_array import DynamicArray - - data1 = self.array1._read_direct(key) - if isinstance(self.array2, DynamicArray): - data2 = self.array2._read_direct(key) - else: - data2 = self.array2 - return data1 * data2 +def transpose(array, axes): + """Permute array dimensions.""" + return array._with_transform(TransposeTransform(array, axes)) -class AddTransform(Transform): - """Element-wise addition.""" - - def __init__(self, array1: 'DynamicArray', array2: Union['DynamicArray', float]): - super().__init__() - self.array1 = array1 - self.array2 = array2 - self.shape = array1.shape - self.chunks = array1.chunks - self.dtype = array1.dtype - - def read(self, key): - # Import here to avoid circular dependency - from dyna_zarr.dynamic_array import DynamicArray - - data1 = self.array1._read_direct(key) - if isinstance(self.array2, DynamicArray): - data2 = self.array2._read_direct(key) - else: - data2 = self.array2 - return data1 + data2 +def reshape(array, shape): + """Reshape array to a new shape (C-order). -class MinTransform(Transform): - """Lazy minimum reduction along specified axes.""" - def __init__(self, array: 'DynamicArray', axis: Optional[int] = None): - super().__init__() - self.array = array - self.axis = axis - - if axis is None: - self.shape = () - self.chunks = None - else: - normalized_axis = axis if axis >= 0 else array.ndim + axis - if normalized_axis < 0 or normalized_axis >= array.ndim: - raise ValueError(f"axis {axis} out of bounds for dimension {array.ndim}") - self.shape = array.shape[:normalized_axis] + array.shape[normalized_axis + 1:] - self.chunks = array.chunks[:normalized_axis] + array.chunks[normalized_axis + 1:] if array.chunks else None - - self.dtype = array.dtype - - def read(self, key): - """Read entire array and compute minimum.""" - full_data = self.array._read_direct(tuple(slice(None) for _ in range(self.array.ndim))) - return np.min(full_data, axis=self.axis) + Memory: ``io.write`` streams this via the reindex writer (per output chunk, one input + chunk at a time), so writing a reshape is hard-bounded to ~1 input chunk + 1 output + chunk regardless of array size. A lazy ``compute()`` / sub-slice still uses a covering + read (heavier, since a flat re-index conflicts with nd chunk layout). + """ + return array._with_transform(ReshapeTransform(array, shape)) -class MaxTransform(Transform): - """Lazy maximum reduction along specified axes.""" - def __init__(self, array: 'DynamicArray', axis: Optional[int] = None): - super().__init__() - self.array = array - self.axis = axis - - if axis is None: - self.shape = () - self.chunks = None - else: - normalized_axis = axis if axis >= 0 else array.ndim + axis - if normalized_axis < 0 or normalized_axis >= array.ndim: - raise ValueError(f"axis {axis} out of bounds for dimension {array.ndim}") - self.shape = array.shape[:normalized_axis] + array.shape[normalized_axis + 1:] - self.chunks = array.chunks[:normalized_axis] + array.chunks[normalized_axis + 1:] if array.chunks else None - - self.dtype = array.dtype - - def read(self, key): - """Read entire array and compute maximum.""" - full_data = self.array._read_direct(tuple(slice(None) for _ in range(self.array.ndim))) - return np.max(full_data, axis=self.axis) +def squeeze(array, axis=None): + """Remove singleton dimensions.""" + return array._with_transform(SqueezeTransform(array, axis)) -# operations class with static methods for creating transforms +def flatten(array): + """Flatten array to 1D (C-order). -class operations: - """ - Array operations following numpy API conventions. - All methods return lazy DynamicArray objects with transforms applied. + Memory: ``io.write`` streams this via the reindex writer, hard-bounded to ~1 input + chunk + 1 output chunk regardless of array size (verified flat: 170MB->679MB arrays + all peak ~135MB). A lazy ``compute()`` still materializes. """ - - @staticmethod - def expand_dims(array: 'DynamicArray', axis: int) -> 'DynamicArray': - """Add a new axis of length 1.""" - transform = ExpandDimsTransform(array, axis) - return array._with_transform(transform) - - @staticmethod - def concatenate(arrays: List['DynamicArray'], axis: int = 0) -> 'DynamicArray': - """Concatenate arrays along an existing axis.""" - if not arrays: - raise ValueError("Need at least one array to concatenate") - transform = ConcatenateTransform(tuple(arrays), axis) - return arrays[0]._with_transform(transform) - - @staticmethod - def stack(arrays: List['DynamicArray'], axis: int = 0) -> 'DynamicArray': - """Stack arrays along a new axis.""" - if not arrays: - raise ValueError("Need at least one array to stack") - transform = StackTransform(tuple(arrays), axis) - return arrays[0]._with_transform(transform) - - @staticmethod - def swap_axes(array: 'DynamicArray', axis1: int, axis2: int) -> 'DynamicArray': - """Swap two axes.""" - transform = SwapAxesTransform(array, axis1, axis2) - return array._with_transform(transform) - - @staticmethod - def transpose(array: 'DynamicArray', axes: Tuple[int, ...]) -> 'DynamicArray': - """Permute array dimensions.""" - transform = TransposeTransform(array, axes) - return array._with_transform(transform) - - @staticmethod - def reshape(array: 'DynamicArray', shape: Tuple[int, ...]) -> 'DynamicArray': - """Reshape array to new shape.""" - transform = ReshapeTransform(array, shape) - return array._with_transform(transform) - - @staticmethod - def squeeze(array: 'DynamicArray', axis: Optional[int] = None) -> 'DynamicArray': - """Remove singleton dimensions.""" - transform = SqueezeTransform(array, axis) - return array._with_transform(transform) - - @staticmethod - def flatten(array: 'DynamicArray') -> 'DynamicArray': - """Flatten array to 1D.""" - transform = FlattenTransform(array) - return array._with_transform(transform) - - @staticmethod - def pad(array: 'DynamicArray', pad_width: Union[int, Tuple]) -> 'DynamicArray': - """Pad array.""" - transform = PadTransform(array, pad_width) - return array._with_transform(transform) - - @staticmethod - def tile(array: 'DynamicArray', reps: Union[int, Tuple]) -> 'DynamicArray': - """Repeat array along dimensions.""" - transform = TileTransform(array, reps) - return array._with_transform(transform) - - @staticmethod - def roll(array: 'DynamicArray', shift: int, axis: Optional[int] = None) -> 'DynamicArray': - """Roll array elements along an axis.""" - transform = RollTransform(array, shift, axis) - return array._with_transform(transform) - - @staticmethod - def flip(array: 'DynamicArray', axis: int) -> 'DynamicArray': - """Flip array along an axis.""" - transform = FlipTransform(array, axis) - return array._with_transform(transform) - - @staticmethod - def clip(array: 'DynamicArray', a_min: Optional[float], a_max: Optional[float]) -> 'DynamicArray': - """Clip array values to a range.""" - transform = ClipTransform(array, a_min, a_max) - return array._with_transform(transform) - - @staticmethod - def abs(array: 'DynamicArray') -> 'DynamicArray': - """Absolute value.""" - transform = AbsTransform(array) - return array._with_transform(transform) - - @staticmethod - def sign(array: 'DynamicArray') -> 'DynamicArray': - """Sign of array elements.""" - transform = SignTransform(array) - return array._with_transform(transform) - - @staticmethod - def round(array: 'DynamicArray', decimals: int = 0) -> 'DynamicArray': - """Round array elements.""" - transform = RoundTransform(array, decimals) - return array._with_transform(transform) - - @staticmethod - def sqrt(array: 'DynamicArray') -> 'DynamicArray': - """Square root.""" - transform = SqrtTransform(array) - return array._with_transform(transform) - - @staticmethod - def where(condition: 'DynamicArray', x: 'DynamicArray', y: 'DynamicArray') -> 'DynamicArray': - """Conditional element selection.""" - transform = WhereTransform(condition, x, y) - return x._with_transform(transform) - - @staticmethod - def multiply(array1: 'DynamicArray', array2: Union['DynamicArray', float]) -> 'DynamicArray': - """Element-wise multiplication.""" - transform = MultiplyTransform(array1, array2) - return array1._with_transform(transform) - - @staticmethod - def add(array1: 'DynamicArray', array2: Union['DynamicArray', float]) -> 'DynamicArray': - """Element-wise addition.""" - transform = AddTransform(array1, array2) - return array1._with_transform(transform) - - @staticmethod - def min(array: 'DynamicArray', axis: Optional[int] = None) -> 'DynamicArray': - """Compute minimum along axis (lazy reduction).""" - transform = MinTransform(array, axis) - return array._with_transform(transform) - - @staticmethod - def max(array: 'DynamicArray', axis: Optional[int] = None) -> 'DynamicArray': - """Compute maximum along axis (lazy reduction).""" - transform = MaxTransform(array, axis) - return array._with_transform(transform) + return array._with_transform(FlattenTransform(array)) -def slice_array(array: 'DynamicArray', key) -> 'DynamicArray': - """Create a lazy slice of an array.""" - transform = SliceTransform(array, key) - return array._with_transform(transform) +def pad(array, pad_width): + """Pad array.""" + return array._with_transform(PadTransform(array, pad_width)) + + +def tile(array, reps): + """Repeat array along dimensions.""" + return array._with_transform(TileTransform(array, reps)) + + +def roll(array, shift, axis=None): + """Roll array elements along an axis.""" + return array._with_transform(RollTransform(array, shift, axis)) + + +def flip(array, axis): + """Flip array along an axis.""" + return array._with_transform(FlipTransform(array, axis)) + + +def rot90(array, k=1, axes=(0, 1)): + """Rotate array by 90 degrees ``k`` times in the plane of ``axes`` (like numpy.rot90). + Composed from the validated flip/transpose transforms, so it stays lazy + backend-agnostic.""" + ndim = array.ndim + a0 = axes[0] if axes[0] >= 0 else ndim + axes[0] + a1 = axes[1] if axes[1] >= 0 else ndim + axes[1] + if a0 == a1 or not (0 <= a0 < ndim and 0 <= a1 < ndim): + raise ValueError(f"invalid rotation axes {axes} for ndim {ndim}") + k %= 4 + if k == 0: + return array + if k == 2: + return flip(flip(array, a0), a1) + perm = list(range(ndim)) + perm[a0], perm[a1] = perm[a1], perm[a0] + if k == 1: + return transpose(flip(array, a1), tuple(perm)) + return flip(transpose(array, tuple(perm)), a1) # k == 3 + + +__all__ = [ + "ConcatenateTransform", "StackTransform", "SliceTransform", "ExpandDimsTransform", + "SwapAxesTransform", "TransposeTransform", "ReshapeTransform", "SqueezeTransform", + "FlattenTransform", "PadTransform", "TileTransform", "RollTransform", "FlipTransform", + "slice_array", + "expand_dims", "concatenate", "stack", "swap_axes", "transpose", "reshape", + "squeeze", "flatten", "pad", "tile", "roll", "flip", "rot90", +] diff --git a/src/dyna_zarr/rechunk.py b/src/dyna_zarr/rechunk.py new file mode 100644 index 0000000..2e723b1 --- /dev/null +++ b/src/dyna_zarr/rechunk.py @@ -0,0 +1,233 @@ +"""Two-phase disk-staged rechunk (Rechunker's algorithm, dask-free). + +Rechunking source chunks -> target chunks directly can blow memory when the chunk grids +don't align. Rechunker's fix: stage through an on-disk intermediate whose chunks divide +BOTH grids, so each hop is chunk-aligned -> each source chunk read once, each target chunk +written once, memory bounded to one chunk. + + int_chunks[d] = gcd(source_chunks[d], target_chunks[d]) # divides both grids + + - source divides target (source finer) -> one pass, consolidate (iterate target chunks). + - target divides source (target finer) -> one pass, split (iterate source chunks). + - otherwise -> two passes via a disk intermediate (int_chunks): + phase 1 source -> intermediate (iterate source chunks; int|source -> aligned) + phase 2 intermediate -> target (iterate target chunks; int|target -> aligned) + +Same-shape only (it's a re-chunk, not a re-shape). reshape/flatten build on top of this by +rechunking to a flat-contiguous layout, then relabeling the shape. +""" + +import math +import shutil +from concurrent.futures import ThreadPoolExecutor +from itertools import product + +import numpy as np +import zarr + +from .utils import parse_dtype +from .operations._backend import asnumpy + +DEFAULT_MAX_MEM = 256 * 1024 * 1024 # per-worker region-buffer budget, in bytes +DEFAULT_MAX_WORKERS = 4 # concurrent read->write regions + + +def _read(src, sl): + from .dynamic_array import DynamicArray + if isinstance(src, DynamicArray): + return asnumpy(src._read_direct(sl)) + return np.asarray(src[sl]) + + +def _expand_region(base_chunk, shape, budget): + """Grow a copy region outward from one ``base_chunk`` (the coarser grid's chunk) by WHOLE + chunk multiples -- innermost axis first (C-order, keeps reads contiguous) -- while the + element count stays within ``budget``. Result is chunk-aligned on both grids (so read-once + / write-once holds) and bounds each worker's buffer to ~budget. For tiny chunks this + batches many into one big I/O; for a chunk already >= budget it stays at one chunk (floor). + """ + ndim = len(shape) + region = [min(int(base_chunk[d]), int(shape[d])) for d in range(ndim)] + for ax in range(ndim - 1, -1, -1): + while region[ax] < shape[ax]: + nxt = min(region[ax] + int(base_chunk[ax]), int(shape[ax])) + prod = 1 + for d in range(ndim): + prod *= nxt if d == ax else region[d] + if prod <= budget: + region[ax] = nxt + else: + break + return tuple(region) + + +def _copy(src, dst, base_chunk, shape, budget, max_workers): + """Copy src->dst region by region. Each region is a multiple of ``base_chunk`` (the COARSER + grid) sized to ``budget`` -> chunk-aligned both sides (read-once / write-once), disjoint -> + parallel-safe (distinct chunk files). Up to ``max_workers`` regions in flight => peak + ~= max_workers * region_bytes.""" + ndim = len(shape) + region = _expand_region(base_chunk, shape, budget) + origins = list(product(*[range(0, shape[d], region[d]) for d in range(ndim)])) + + def task(origin): + sl = tuple(slice(o, min(o + region[d], shape[d])) + for d, o in zip(range(ndim), origin)) + dst[sl] = _read(src, sl) + + if max_workers <= 1 or len(origins) <= 1: + for o in origins: + task(o) + else: + # ThreadPoolExecutor runs <= max_workers tasks concurrently; reads happen INSIDE the + # task, so at most max_workers regions are resident at once. list() surfaces exceptions. + with ThreadPoolExecutor(max_workers=max_workers) as ex: + list(ex.map(task, origins)) + + +def rechunk(source, target_chunks, output_path, dtype=None, zarr_format=2, + intermediate_path=None, max_mem=DEFAULT_MAX_MEM, max_workers=DEFAULT_MAX_WORKERS): + """Rechunk ``source`` (a DynamicArray or zarr, same output shape) to ``target_chunks`` at + ``output_path``, disk-staged and memory-bounded. Returns output_path. + + Memory model (per-worker, matching io.write's region_size_mb + max_workers): each worker + buffers up to ``max_mem`` bytes (one region = as many whole aligned chunks as fit, >= one + chunk); ``max_workers`` regions run concurrently, so peak ~= ``max_workers * max_mem``. + """ + shape = tuple(int(s) for s in source.shape) + ndim = len(shape) + sc = tuple(int(c) for c in (source.chunks or shape)) + tc = tuple(int(c) for c in target_chunks) + if len(tc) != ndim: + raise ValueError(f"target_chunks {tc} rank != source rank {ndim}") + dt = parse_dtype(dtype if dtype is not None else source.dtype)[0] + budget = max(1, int(max_mem) // dt.itemsize) # region budget in elements + + out = zarr.open(str(output_path), mode="w", shape=shape, chunks=tc, + dtype=dt, zarr_format=zarr_format) + + src_divides_tgt = all(tc[d] % sc[d] == 0 for d in range(ndim)) # source finer + tgt_divides_src = all(sc[d] % tc[d] == 0 for d in range(ndim)) # target finer + + if src_divides_tgt: + _copy(source, out, tc, shape, budget, max_workers) # consolidate: base = target chunk + elif tgt_divides_src: + _copy(source, out, sc, shape, budget, max_workers) # split: base = source chunk + else: + ic = tuple(math.gcd(sc[d], tc[d]) for d in range(ndim)) + ipath = intermediate_path or (str(output_path) + ".rechunk_int.zarr") + interm = zarr.open(ipath, mode="w", shape=shape, chunks=ic, dtype=dt, + zarr_format=zarr_format) + _copy(source, interm, sc, shape, budget, max_workers) # phase 1 (int | source) + _copy(interm, out, tc, shape, budget, max_workers) # phase 2 (int | target) + try: + shutil.rmtree(ipath) + except Exception: + pass + return output_path + + +def flatten_write(source, output_path, output_chunks=None, max_mem=DEFAULT_MAX_MEM, + max_workers=DEFAULT_MAX_WORKERS, dtype=None, zarr_format=2): + """Flatten ``source`` (nd) to 1D at ``output_path``, memory-bounded + read-once. + + Two steps: (1) rechunk source to a FLAT-CONTIGUOUS layout (read-once source via the + rechunk engine); (2) relabel those flat-contiguous slabs to the 1D output (read-once). + The nd-chunk -> flat-order reorder happens inside the disk-staged rechunk, so RAM stays + bounded to ~one flat-contiguous unit regardless of array size. + + Sizing the flat-contiguous unit to ``max_mem``: a chunk is a single C-order contiguous + flat run iff every axis AFTER some split axis ``a`` is full and every axis BEFORE is 1, + i.e. ``(1,..,1, ca, D_{a+1}..D_last)`` -> ``ca * suffix[a]`` contiguous elements + (``suffix[a] = prod(shape[a+1:])``). Pick the shallowest ``a`` whose full-trailing block + ``suffix[a]`` already fits the budget, then take as many ``ca`` steps as fit. This bounds + the unit to ``max_mem`` even when a whole trailing row (split axis 0) would blow it. + """ + shape = tuple(int(s) for s in source.shape) + ndim = len(shape) + dt = parse_dtype(dtype if dtype is not None else source.dtype)[0] + budget = max(1, int(max_mem) // dt.itemsize) # in elements + + suffix = [1] * ndim # suffix[d] = prod(shape[d+1:]) + for d in range(ndim - 2, -1, -1): + suffix[d] = suffix[d + 1] * shape[d + 1] + + a = ndim - 1 # split axis (deepest = last) + for d in range(ndim): + if suffix[d] <= budget: # trailing block already fits + a = d + break + ca = max(1, min(shape[a], budget // suffix[a])) # steps of the split axis that fit + ff_chunks = tuple(1 if d < a else (ca if d == a else shape[d]) for d in range(ndim)) + + ff_path = str(output_path) + ".flatten_ff.zarr" + rechunk(source, ff_chunks, ff_path, dtype=dt, zarr_format=zarr_format, + max_mem=max_mem, max_workers=max_workers) # read-once source, parallel + ff = zarr.open(ff_path, mode="r") + + N = suffix[0] * shape[0] + unit = ca * suffix[a] # elements per flat-contiguous slab + oc = tuple(output_chunks) if output_chunks is not None else (min(N, unit),) + out = zarr.open(str(output_path), mode="w", shape=(N,), chunks=oc, dtype=dt, + zarr_format=zarr_format) + + # Relabel flat-contiguous units to the 1D output, in C-order: leading axes one index at a + # time, split axis in steps of ca, trailing axes whole -> each unit is a contiguous output + # range. Kept SEQUENTIAL: user output_chunks need not align to units, so parallel writes + # could collide on a shared output chunk. Memory here = one unit (<= max_mem); it's mostly + # I/O, and the expensive reorder already happened (parallel) in the rechunk above. + lead_ranges = [range(shape[d]) for d in range(a)] + [range(0, shape[a], ca)] + for origin in product(*lead_ranges): + lead, ka = origin[:a], origin[a] + da = min(ca, shape[a] - ka) + sl = tuple([slice(i, i + 1) for i in lead] + [slice(ka, ka + da)] + + [slice(None)] * (ndim - a - 1)) + block = np.asarray(ff[sl]) # one ff slab (read-once) + f0 = sum(lead[d] * suffix[d] for d in range(a)) + ka * suffix[a] + out[f0:f0 + da * suffix[a]] = block.reshape(-1) + shutil.rmtree(ff_path, ignore_errors=True) + return output_path + + +def reshape_write(source, target_shape, output_path, output_chunks=None, + max_mem=DEFAULT_MAX_MEM, max_workers=DEFAULT_MAX_WORKERS, + dtype=None, zarr_format=2): + """Reshape ``source`` -> ``target_shape`` (C-order) at ``output_path``, memory-bounded. + + Two staged steps: (1) flatten source to a 1D CONTIGUOUS ``F`` (read-once source, via the + disk-staged rechunk engine); (2) reindex ``F`` -> ``target_shape`` streaming per output + chunk. Feeding reindex a 1D-contiguous input is the key: reindex was only slow on nd + sources because a flat range forced whole nd chunks -- over contiguous ``F`` each output + chunk maps to contiguous ``F`` ranges, so there's no chunk-amplification. Peak memory ~ + max(one flatten unit [<= max_mem], one F chunk + one output chunk of the reindex). + + Shape change happens only at the F -> target flat-identity relabel (output flat index == + input flat index); everything else is same-shape staging. + """ + from .dynamic_array import DynamicArray + from .reindex import reindex_write + + target_shape = tuple(int(s) for s in target_shape) + src_shape = tuple(int(s) for s in source.shape) + if int(np.prod(src_shape)) != int(np.prod(target_shape)): + raise ValueError(f"reshape needs equal size: {src_shape} -> {target_shape}") + dt = parse_dtype(dtype if dtype is not None else source.dtype)[0] + + N = int(np.prod(target_shape)) + if output_chunks is None: + output_chunks = ((min(N, 1 << 20),) if len(target_shape) == 1 + else tuple(min(s, 256) for s in target_shape)) + else: + output_chunks = tuple(int(c) for c in output_chunks) + + f_path = str(output_path) + ".reshape_1d.zarr" + f_chunk = (min(N, 1 << 22),) # 1D F chunks (~16MB f32), lean reindex input + flatten_write(source, f_path, output_chunks=f_chunk, max_mem=max_mem, + max_workers=max_workers, dtype=dt, zarr_format=zarr_format) + F = DynamicArray(zarr.open(f_path, mode="r")) + try: + reindex_write(F, target_shape, output_path, output_chunks, + dtype=dt, zarr_format=zarr_format) + finally: + shutil.rmtree(f_path, ignore_errors=True) + return output_path diff --git a/src/dyna_zarr/reindex.py b/src/dyna_zarr/reindex.py new file mode 100644 index 0000000..035c632 --- /dev/null +++ b/src/dyna_zarr/reindex.py @@ -0,0 +1,104 @@ +"""Streaming reindex writer for reshape / flatten (a C-order flat re-index). + +These conflict with nd chunk layout, so the region writer either materializes the whole +input or thrashes chunks. This engine instead is **output-chunk driven with per-input-chunk +streaming**: + + for each OUTPUT chunk O: + buffer = empty(O) + for each INPUT chunk that feeds O (read ONE AT A TIME, then released): + scatter its overlapping elements into buffer + write O once + +So each output chunk is produced by a single task (no write race, at any parallelism), and +memory is hard-bounded to **one input chunk + one output chunk** (+ the O-sized index +arrays), regardless of how many input chunks feed O or how far apart they are. The cost is +read amplification -- an input chunk is re-read once per output chunk that needs it -- which +an optional bounded LRU cache (``cache_size`` input chunks) trades back for memory. + +reshape/flatten are the flat-identity case: output flat index == input flat index. +""" + +from itertools import product + +import numpy as np +import zarr + +from .utils import parse_dtype +from .operations._backend import asnumpy + + +def _c_strides(shape): + strides = [1] * len(shape) + for a in range(len(shape) - 2, -1, -1): + strides[a] = strides[a + 1] * shape[a + 1] + return strides + + +def reindex_write(input_array, out_shape, output_path, output_chunks, + dtype=None, zarr_format=2, cache_size=0): + """Write ``input_array`` reshaped (C-order) to ``out_shape`` at ``output_path``, + streaming per output chunk. Memory ~ one input chunk + one output chunk + O-sized index + arrays. ``cache_size`` (input chunks) optionally caches recent input chunks to cut + re-reads.""" + in_shape = tuple(int(s) for s in input_array.shape) + out_shape = tuple(int(s) for s in out_shape) + if int(np.prod(in_shape)) != int(np.prod(out_shape)): + raise ValueError(f"reindex needs equal size: {in_shape} vs {out_shape}") + in_chunks = tuple(int(c) for c in (input_array.chunks or in_shape)) + output_chunks = tuple(int(c) for c in output_chunks) + dt = parse_dtype(dtype if dtype is not None else input_array.dtype)[0] + + out = zarr.open(str(output_path), mode="w", shape=out_shape, + chunks=output_chunks, dtype=dt, zarr_format=zarr_format) + + in_ndim, out_ndim = len(in_shape), len(out_shape) + out_strides = _c_strides(out_shape) + grid = tuple((in_shape[d] + in_chunks[d] - 1) // in_chunks[d] for d in range(in_ndim)) + + cache = {} if cache_size > 0 else None + order = [] + + def read_input_chunk(cid, cc): + if cache is not None and cid in cache: + return cache[cid] + sl = tuple(slice(cc[d] * in_chunks[d], + min(cc[d] * in_chunks[d] + in_chunks[d], in_shape[d])) + for d in range(in_ndim)) + block = np.asarray(asnumpy(input_array._read_direct(sl))) + if cache is not None: + cache[cid] = block + order.append(cid) + while len(order) > cache_size: + cache.pop(order.pop(0), None) + return block + + for origin in product(*[range(0, out_shape[d], output_chunks[d]) for d in range(out_ndim)]): + o_sl = tuple(slice(origin[d], min(origin[d] + output_chunks[d], out_shape[d])) + for d in range(out_ndim)) + o_shape = tuple(s.stop - s.start for s in o_sl) + + # flat index (C-order in out_shape) of every element of this output chunk + flat = np.zeros(o_shape, dtype=np.int64) + for d in range(out_ndim): + coord = (o_sl[d].start + np.arange(o_shape[d], dtype=np.int64)) * np.int64(out_strides[d]) + shp = [1] * out_ndim + shp[d] = o_shape[d] + flat += coord.reshape(shp) + flat = flat.ravel() + + # same flat index in the INPUT -> input coords + which input chunk each belongs to + in_coords = np.unravel_index(flat, in_shape) + chunk_lin = np.ravel_multi_index( + tuple(in_coords[d] // in_chunks[d] for d in range(in_ndim)), grid) + + obuf = np.empty(flat.shape[0], dtype=dt) + for cid in np.unique(chunk_lin): + cc = np.unravel_index(int(cid), grid) + block = read_input_chunk(int(cid), cc) # ONE input chunk at a time + mask = chunk_lin == cid + local = tuple(in_coords[d][mask] - cc[d] * in_chunks[d] for d in range(in_ndim)) + obuf[mask] = block[local] + out[o_sl] = obuf.reshape(o_shape) + + return output_path diff --git a/src/dyna_zarr/tiff_reader.py b/src/dyna_zarr/tiff_reader.py index fa40926..e33412c 100644 --- a/src/dyna_zarr/tiff_reader.py +++ b/src/dyna_zarr/tiff_reader.py @@ -1,314 +1,35 @@ -""" -TIFF File Reader with Concurrent Access Support - -Provides lazy, thread-safe reading of TIFF files using tifffile's zarr bridge. -This enables efficient parallel I/O for multi-dimensional scientific TIFF files. +"""Lazy TIFF reading via tifffile's zarr bridge. + +A TIFF is opened as a lazy, chunked zarr array through ``tifffile``'s ``aszarr`` bridge and +handed straight to :class:`~dyna_zarr.DynamicArray`. All laziness, slicing, chunk-wise reads +and memory-boundedness then come from ``DynamicArray``'s pull model (``SliceTransform`` / +``_read_direct``) -- exactly as for a zarr store -- so there is no TIFF-specific slice logic +to maintain. The tifffile store stays open for as long as the returned array references it +(released on garbage collection), which is what lazy TIFF reading requires. """ import tifffile -import tensorstore as ts -import numpy as np -from pathlib import Path -import json import zarr -class TiffZarrReader: - """ - Lazy reader for TIFF files with concurrent access support. - - Uses tifffile's aszarr() method to expose TIFF as a zarr array, - enabling thread-safe parallel reads. The interface mimics TensorStore - for compatibility with existing code. - - Performance characteristics: - - Opening: Very fast (~0.02s) - only reads metadata - - Slicing: Lazy - stores slice info, defers data loading until .result() or .read().result() - - Concurrent reads: Thread-safe via zarr's locking mechanism - - Limitation: TIFF single-file format doesn't parallelize as well as - multi-file Zarr format (use large regions, moderate thread counts) - """ - def __init__(self, tiff_path, zarr_array=None, slice_key=None, parent_shape=None): - if zarr_array is None: - # Initial construction from path - self.tiff_path = str(Path(tiff_path).resolve()) - self._tif = tifffile.TiffFile(self.tiff_path) - raw_store = self._tif.aszarr() - - # Open with zarr - keeps it lazy - self._zarr_array = zarr.open(raw_store, mode='r') - self._slice_key = None # No slicing yet - self._parent_shape = None - - self.shape = tuple(self._zarr_array.shape) - self.dtype = self._zarr_array.dtype - self.chunks = self._zarr_array.chunks if hasattr(self._zarr_array, 'chunks') else None - else: - # Construction from sliced zarr array - store slice for lazy evaluation - self.tiff_path = tiff_path - self._tif = None - self._zarr_array = zarr_array - self._slice_key = slice_key # Store the slice, don't apply it yet! - self._parent_shape = parent_shape # Shape before this slice was applied - - # Compute shape from the slice WITHOUT loading data - if slice_key is not None: - # Use parent_shape (previous slice's result shape) if available - base_shape = parent_shape if parent_shape is not None else zarr_array.shape - self.shape = self._compute_sliced_shape(base_shape, slice_key) - else: - self.shape = tuple(parent_shape if parent_shape is not None else zarr_array.shape) - - self.dtype = zarr_array.dtype - self.chunks = None - - def _compute_sliced_shape(self, original_shape, key): - """Compute the shape that would result from slicing, without actually slicing.""" - # Normalize the key to a tuple - if not isinstance(key, tuple): - key = (key,) - - # Handle chained slices - if key is (first_key, second_key), we need to think differently - if len(key) == 2 and not isinstance(key[0], (int, slice)): - # This is a chained slice from _combine_slices - DON'T use it for shape computation - # Instead, just use the last slice in the chain - # Actually, this shouldn't happen because parent_shape should already account for earlier slices - pass - - # Pad with full slices if needed - key = key + (slice(None),) * (len(original_shape) - len(key)) - - new_shape = [] - for dim_size, idx in zip(original_shape, key): - if isinstance(idx, slice): - start, stop, step = idx.indices(dim_size) - length = len(range(start, stop, step)) - new_shape.append(length) - elif isinstance(idx, int): - # Integer indexing removes dimension - continue - else: - # For other types (arrays, etc), just use original size - new_shape.append(dim_size) - - return tuple(new_shape) - - def __getitem__(self, key): - """ - Lazy slicing - stores slice info without loading data. - - IMPORTANT: This does NOT load data! It returns a new TiffZarrReader - with the slice stored for later evaluation when .result() is called. - """ - # For shape computation, we only need the NEW key applied to current shape - # For materialization, we need ALL keys applied sequentially - # So store both: combined_key for materialization, key for shape computation - - if self._slice_key is not None: - # Chain slices together for materialization - combined_key = self._combine_slices(self._slice_key, key) - else: - combined_key = key - - # Compute shape using ONLY the new key on current shape - # (parent_shape is passed as self.shape, which already includes previous slices) - new_shape = self._compute_sliced_shape(self.shape, key) - - # Create new reader - new_reader = TiffZarrReader( - self.tiff_path, - zarr_array=self._zarr_array, - slice_key=combined_key, - parent_shape=new_shape # Pass computed shape as parent for next slice - ) - # Override the shape with our computed one - new_reader.shape = new_shape - - return new_reader - - def _combine_slices(self, first_key, second_key): - """Combine two slice operations into one.""" - # For simplicity, we can just store them as a tuple - # and apply them sequentially when needed - # A more sophisticated implementation could optimize this - return (first_key, second_key) - - def read(self): - """ - Async-style read that returns a Future-like object. - - For compatibility with TensorStore-style code that uses .read().result() - This is when data is ACTUALLY loaded from disk. - """ - class FutureResult: - def __init__(self, reader): - self._reader = reader - def result(self): - return self._reader._materialize() - - return FutureResult(self) - - def result(self): - """Direct result() call - THIS is when data is actually loaded from disk.""" - return self._materialize() - - def _materialize(self): - """ - Actually load the data from disk by applying stored slices. - - IMPORTANT: This is where zarr array slicing happens, which materializes data! - Optimized to apply slices sequentially without loading full array. - """ - if self._slice_key is None: - # No slicing, return full array - return np.asarray(self._zarr_array[:]) - else: - # Apply the stored slice(s) - # Check if this is a chained slice (stored as tuple by _combine_slices) - if self._is_chained_slice(self._slice_key): - # Chained slices - apply sequentially on zarr array, then on numpy arrays - slices_to_apply = self._flatten_chained_slices(self._slice_key) - - # Apply first slice on zarr array (this loads data from disk) - result = self._zarr_array[slices_to_apply[0]] - if not isinstance(result, np.ndarray): - result = np.asarray(result) - - # Apply remaining slices on numpy array (in-memory) - for s in slices_to_apply[1:]: - result = result[s] - - return result - else: - # Single slice - result = self._zarr_array[self._slice_key] - if isinstance(result, np.ndarray): - return result - else: - # Scalar or other type - return np.asarray(result) - - def _is_chained_slice(self, key): - """Check if a key represents chained slices from _combine_slices.""" - # A chained slice is a 2-tuple where first element is not a slice/int - if isinstance(key, tuple) and len(key) == 2: - first = key[0] - # If first element is itself a tuple or a chained slice, it's chained - if isinstance(first, tuple): - return True - return False - - def _flatten_chained_slices(self, key): - """Flatten nested chained slices into a list of slice tuples.""" - slices = [] - if self._is_chained_slice(key): - first_key, second_key = key - # Recursively flatten first_key if it's also chained - if self._is_chained_slice(first_key): - slices.extend(self._flatten_chained_slices(first_key)) - else: - slices.append(first_key) - # Add second_key - slices.append(second_key) - else: - slices.append(key) - return slices - - def __array__(self, dtype=None): - """ - Support numpy array protocol for np.asarray() conversion. - - CRITICAL: This MUST call _materialize() to respect stored slices! - Otherwise np.asarray() would load the entire array, ignoring slices. - """ - arr = self._materialize() - if dtype is not None: - return arr.astype(dtype) - return arr - - @property - def spec(self): - """Provide a spec property for compatibility with TensorStore-style code.""" - return { - 'driver': 'tiff+zarr', - 'path': self.tiff_path, - 'shape': self.shape, - 'dtype': str(self.dtype), - 'chunks': self.chunks - } - - -class ArrayResult: - """Wrapper for already-materialized numpy arrays.""" - def __init__(self, array): - self._array = array - self.shape = array.shape - self.dtype = array.dtype - - def read(self): - class FutureResult: - def __init__(self, data): - self._data = data - def result(self): - return self._data - return FutureResult(self._array) - - def result(self): - """Direct result() call.""" - return self._array - - def __array__(self, dtype=None): - """Support numpy array protocol.""" - if dtype is not None: - return self._array.astype(dtype) - return self._array - - def __getitem__(self, key): - return ArrayResult(self._array[key]) - - -class ScalarResult: - """Wrapper for scalar values.""" - def __init__(self, value): - self._value = value - self.shape = () - self.dtype = np.array(value).dtype - - def read(self): - class FutureResult: - def __init__(self, data): - self._data = data - def result(self): - return self._data - return FutureResult(self._value) - - def result(self): - """Direct result() call.""" - return self._value - - def __array__(self, dtype=None): - """Support numpy array protocol.""" - arr = np.array(self._value) - if dtype is not None: - return arr.astype(dtype) - return arr +def open_tiff_zarr(path): + """Open a TIFF as a lazy zarr array via tifffile's zarr bridge (the raw backend object). - -def read_tiff_lazy(tiff_path): - """ - Open a TIFF file for lazy, thread-safe reading. - - Fast opening (~0.02s) - only reads metadata, not pixel data. - Returns a reader that provides: - - Lazy slicing: data only loaded on access - - Thread-safe: multiple threads can read concurrently - - TensorStore-compatible API: supports .read().result() pattern - - Efficient chunked access via tifffile's zarr bridge - - Args: - tiff_path: Path to the TIFF file - - Returns: - TiffZarrReader with lazy, concurrent read support + For a plain single-series TIFF this is a ``zarr.Array``; for a multi-series/multi-level + file tifffile yields a group, in which case the first array (series/level 0) is returned. """ - return TiffZarrReader(tiff_path) + store = tifffile.imread(str(path), aszarr=True) # ZarrTiffStore (lazy; reads on access) + obj = zarr.open(store, mode="r") + if isinstance(obj, zarr.Group): + for key in obj: # multi-series/level -> take the first array + item = obj[key] + if isinstance(item, zarr.Array): + return item + raise ValueError(f"TIFF at {path!r} exposes no readable array via aszarr") + return obj + + +def read_tiff_lazy(path): + """Read a TIFF as a lazy, memory-bounded :class:`~dyna_zarr.DynamicArray`.""" + from .dynamic_array import DynamicArray + return DynamicArray(open_tiff_zarr(path)) diff --git a/tests/test_creation.py b/tests/test_creation.py new file mode 100644 index 0000000..c8c8dd0 --- /dev/null +++ b/tests/test_creation.py @@ -0,0 +1,79 @@ +""" +Creation ops (nullary generative sources): zeros/ones/full/empty/random + *_like. + +These synthesize each region lazily (no underlying array), compose with other ops, and +stream to disk. ``random`` must be position-deterministic: any sub-slice equals the whole +array sliced, and io.write matches compute. +""" +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray, io, operations as ops + + +def random_key(rng, shape): + key = [] + for size in shape: + c = rng.integers(0, 4) + if c == 0: + key.append(int(rng.integers(0, size))) + elif c == 1: + key.append(slice(None)) + elif c == 2: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b))) + else: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b), int(rng.integers(1, 3)))) + return tuple(key) + + +def test_deterministic_creation(): + for op, npf in [(ops.zeros, np.zeros), (ops.ones, np.ones)]: + a = op((4, 6), dtype=np.float32) + assert a.shape == (4, 6) and a.dtype == np.float32 + np.testing.assert_array_equal(a.compute(), npf((4, 6), np.float32)) + np.testing.assert_array_equal(ops.full((3, 5), 7.0).compute(), np.full((3, 5), 7.0)) + # full infers dtype from fill_value + assert ops.full((2, 2), 3).dtype == np.array(3).dtype + + +def test_creation_subslice_and_chain(): + z = ops.ones((8, 8), dtype=np.float32) + np.testing.assert_array_equal(z[2:5, ::2].compute(), np.ones((8, 8), np.float32)[2:5, ::2]) + np.testing.assert_allclose((ops.ones((4, 4)) * 3 + 1).compute(), np.full((4, 4), 4.0)) + + +def test_like(): + base = ops.zeros((3, 5), dtype=np.int16) + assert ops.full_like(base, 9).dtype == np.int16 + np.testing.assert_array_equal(ops.full_like(base, 9).compute(), np.full((3, 5), 9, np.int16)) + np.testing.assert_array_equal(ops.ones_like(base).compute(), np.ones((3, 5), np.int16)) + + +def test_random_position_deterministic(): + r = ops.random((6, 8, 10), seed=42) + whole = r.compute() + assert whole.shape == (6, 8, 10) and whole.dtype == np.float32 + assert 0.0 <= whole.min() and whole.max() < 1.0 + np.testing.assert_array_equal(whole, r.compute()) # idempotent + rng = np.random.default_rng(1) + for _ in range(40): # chunk-invariant sub-slices + k = random_key(rng, (6, 8, 10)) + np.testing.assert_array_equal(np.asarray(r[k].compute()), whole[k]) + # separate seeds differ + assert not np.array_equal(ops.random((4, 4), seed=1).compute(), + ops.random((4, 4), seed=2).compute()) + + +def test_write_generative(tmp_path): + for name, pipe, ref in [ + ("full", ops.full((16, 32, 32), 5.0, dtype=np.float32), np.full((16, 32, 32), 5.0, np.float32)), + ("random", ops.random((16, 32, 32), seed=7), None), + ]: + out = str(tmp_path / f"{name}.zarr") + io.write(pipe, out, zarr_format=2, chunks=(4, 16, 16)) + written = zarr.open(out, mode="r")[:] + expected = ref if ref is not None else pipe.compute() # write must equal compute + np.testing.assert_array_equal(written, expected) diff --git a/tests/test_neighborhood.py b/tests/test_neighborhood.py new file mode 100644 index 0000000..6a353ef --- /dev/null +++ b/tests/test_neighborhood.py @@ -0,0 +1,122 @@ +""" +Correctness + chunk-invariance for the map_overlap neighbourhood primitive. + +Each neighbourhood filter must equal scipy applied to the *whole* array, no matter what +region size drives the read. We check two things against a scipy reference: + +1. **Full compute** ``op(da).compute() == scipy(arr)`` +2. **Random sub-slice** ``op(da)[k] == scipy(arr)[k]`` over many random keys AND several + chunkings -- the real test that each read pulls its own halo correctly (chunk/region + invariance) and that boundaries are handled exactly. +""" + +import numpy as np +import pytest +import zarr +from scipy import ndimage as ndi + +from dyna_zarr import DynamicArray, operations as ops + + +SHAPE = (12, 16, 20) + + +def da_from(arr, chunks): + return DynamicArray(zarr.array(arr, chunks=chunks)) + + +def random_key(rng, shape): + key = [] + for size in shape: + c = rng.integers(0, 4) + if c == 0: + key.append(int(rng.integers(0, size))) + elif c == 1: + key.append(slice(None)) + elif c == 2: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b))) + else: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b), int(rng.integers(1, 3)))) + return tuple(key) + + +_KERNEL = np.random.default_rng(7).random((3, 3, 3)).astype(np.float32) # fixed conv kernel + + +# name -> (dyna_op, scipy_reference); reference uses mode='reflect' to match default boundary +OPS = { + "convolve_3": (lambda da: ops.convolve(da, _KERNEL), + lambda a: ndi.convolve(a, _KERNEL, mode="reflect")), + "correlate_3": (lambda da: ops.correlate(da, _KERNEL), + lambda a: ndi.correlate(a, _KERNEL, mode="reflect")), + "gaussian_s2": (lambda da: ops.gaussian_filter(da, 2.0), + lambda a: ndi.gaussian_filter(a, 2.0, mode="reflect")), + "gaussian_aniso": (lambda da: ops.gaussian_filter(da, (1.0, 2.0, 0.5)), + lambda a: ndi.gaussian_filter(a, (1.0, 2.0, 0.5), mode="reflect")), + "uniform_5": (lambda da: ops.uniform_filter(da, 5), + lambda a: ndi.uniform_filter(a, 5, mode="reflect")), + "uniform_even_4": (lambda da: ops.uniform_filter(da, 4), + lambda a: ndi.uniform_filter(a, 4, mode="reflect")), + "median_3": (lambda da: ops.median_filter(da, 3), + lambda a: ndi.median_filter(a, 3, mode="reflect")), + "minimum_3": (lambda da: ops.minimum_filter(da, 3), + lambda a: ndi.minimum_filter(a, 3, mode="reflect")), + "maximum_3": (lambda da: ops.maximum_filter(da, 3), + lambda a: ndi.maximum_filter(a, 3, mode="reflect")), + "grey_erosion_3": (lambda da: ops.grey_erosion(da, 3), + lambda a: ndi.grey_erosion(a, size=3, mode="reflect")), + "grey_dilation_3": (lambda da: ops.grey_dilation(da, 3), + lambda a: ndi.grey_dilation(a, size=3, mode="reflect")), + "laplace": (lambda da: ops.laplace(da), + lambda a: ndi.laplace(a, mode="reflect")), + "gaussian_laplace": (lambda da: ops.gaussian_laplace(da, 2.0), + lambda a: ndi.gaussian_laplace(a, 2.0, mode="reflect")), + "gauss_grad_mag": (lambda da: ops.gaussian_gradient_magnitude(da, 2.0), + lambda a: ndi.gaussian_gradient_magnitude(a, 2.0, mode="reflect")), +} + + +@pytest.fixture(scope="module") +def arr(): + return np.random.default_rng(0).random(SHAPE).astype(np.float32) + + +@pytest.mark.parametrize("op_name", list(OPS)) +def test_full_compute_matches_scipy(arr, op_name): + dyna_op, ref_op = OPS[op_name] + da = da_from(arr, chunks=(4, 4, 5)) + np.testing.assert_allclose(dyna_op(da).compute(), ref_op(arr), atol=1e-5, + err_msg=f"{op_name}: full compute != scipy") + + +@pytest.mark.parametrize("chunks", [(12, 16, 20), (3, 4, 5), (5, 7, 6), (1, 16, 20)]) +def test_random_subslice_chunk_invariant(arr, chunks): + """op(da)[k] == scipy(arr)[k] over random keys and several chunkings.""" + rng = np.random.default_rng(1234) + failures = [] + for op_name, (dyna_op, ref_op) in OPS.items(): + da = da_from(arr, chunks=chunks) + ref = ref_op(arr) + result = dyna_op(da) + for _ in range(15): + k = random_key(rng, SHAPE) + try: + got = result[k].compute() + np.testing.assert_allclose(got, ref[k], atol=1e-5) + except Exception as e: + failures.append(f"{op_name} chunks={chunks} key={k}: " + f"{type(e).__name__}: {str(e).splitlines()[-1][:80]}") + break + assert not failures, "map_overlap chunk-invariance failures:\n" + "\n".join(failures) + + +def test_write_readback_matches_scipy(arr, tmp_path): + """End-to-end: gaussian through io.write (region-streamed) == scipy(whole).""" + from dyna_zarr import io + da = da_from(arr, chunks=(4, 4, 5)) + out = str(tmp_path / "gauss.zarr") + io.write(ops.gaussian_filter(da, 2.0), out, zarr_format=2, chunks=(4, 4, 5)) + written = zarr.open(out, mode="r")[:] + np.testing.assert_allclose(written, ndi.gaussian_filter(arr, 2.0, mode="reflect"), atol=1e-5) diff --git a/tests/test_new_ops.py b/tests/test_new_ops.py new file mode 100644 index 0000000..9626c9c --- /dev/null +++ b/tests/test_new_ops.py @@ -0,0 +1,94 @@ +""" +Correctness + chunk-invariance for the ops added to match ome_zarr_pro's dask surface: +Tier 1 (isin, digitize, rot90) and Tier 2 (var, std, argmin, argmax, diff, gradient). + +Each must equal numpy for full compute AND for random sub-slices across several chunkings +(the read-key math / streaming must be region-independent). +""" +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray, operations as ops + +SHAPE = (6, 8, 10) + + +def da_from(arr, chunks): + return DynamicArray(zarr.array(arr, chunks=chunks)) + + +def random_key(rng, shape): + key = [] + for size in shape: + c = rng.integers(0, 4) + if c == 0: + key.append(int(rng.integers(0, size))) + elif c == 1: + key.append(slice(None)) + elif c == 2: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b))) + else: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b), int(rng.integers(1, 3)))) + return tuple(key) + + +@pytest.fixture(scope="module") +def arr(): + rng = np.random.default_rng(0) + a = rng.random(SHAPE).astype(np.float32) + a[a < 0.1] = 0.25 # a few exact bin values for isin/digitize + return a + + +# name -> (dyna_op, numpy_ref, exact?) -- all support sub-slicing +OPS = { + "isin": (lambda d: ops.isin(d, [0.25, 0.5]), lambda a: np.isin(a, [0.25, 0.5]), True), + "digitize": (lambda d: ops.digitize(d, [0.25, 0.5, 0.75]), lambda a: np.digitize(a, [0.25, 0.5, 0.75]), True), + "rot90_k1": (lambda d: ops.rot90(d, 1, (1, 2)), lambda a: np.rot90(a, 1, (1, 2)), False), + "rot90_k3": (lambda d: ops.rot90(d, 3, (0, 2)), lambda a: np.rot90(a, 3, (0, 2)), False), + "var_a0": (lambda d: ops.var(d, 0), lambda a: a.var(0), False), + "std_a1": (lambda d: ops.std(d, 1), lambda a: a.std(1), False), + "var_ddof1": (lambda d: ops.var(d, 2, ddof=1), lambda a: a.var(2, ddof=1), False), + "argmin_a0": (lambda d: ops.argmin(d, 0), lambda a: a.argmin(0), True), + "argmax_a2": (lambda d: ops.argmax(d, 2), lambda a: a.argmax(2), True), + "diff_a2": (lambda d: ops.diff(d, axis=2), lambda a: np.diff(a, axis=2), False), + "diff_n2_a0": (lambda d: ops.diff(d, 2, axis=0), lambda a: np.diff(a, 2, axis=0), False), + "gradient_a1":(lambda d: ops.gradient(d, axis=1), lambda a: np.gradient(a, axis=1), False), +} + + +@pytest.mark.parametrize("name", list(OPS)) +def test_full_compute_matches_numpy(arr, name): + dyna_op, ref_op, exact = OPS[name] + got = np.asarray(dyna_op(da_from(arr, (2, 3, 4))).compute()) + ref = np.asarray(ref_op(arr)) + assert got.shape == ref.shape, f"{name}: shape {got.shape} != {ref.shape}" + if exact: + np.testing.assert_array_equal(got, ref) + else: + np.testing.assert_allclose(got, ref, rtol=1e-4, atol=1e-4) + + +@pytest.mark.parametrize("chunks", [SHAPE, (2, 3, 4), (1, 8, 10), (3, 1, 5)]) +def test_random_subslice_chunk_invariant(arr, chunks): + rng = np.random.default_rng(7) + failures = [] + for name, (dyna_op, ref_op, exact) in OPS.items(): + result = dyna_op(da_from(arr, chunks)) + ref = np.asarray(ref_op(arr)) + for _ in range(12): + k = random_key(rng, ref.shape) + try: + got = np.asarray(result[k].compute()) + if exact: + np.testing.assert_array_equal(got, ref[k]) + else: + np.testing.assert_allclose(got, ref[k], rtol=1e-4, atol=1e-4) + except Exception as e: + failures.append(f"{name} chunks={chunks} key={k}: " + f"{type(e).__name__}: {str(e).splitlines()[-1][:70]}") + break + assert not failures, "new-op chunk-invariance failures:\n" + "\n".join(failures) diff --git a/tests/test_numpy_protocol.py b/tests/test_numpy_protocol.py new file mode 100644 index 0000000..0fbd9cb --- /dev/null +++ b/tests/test_numpy_protocol.py @@ -0,0 +1,130 @@ +""" +NumPy-protocol surface on DynamicArray: array METHODS (.astype/.clip/.round) and the ufunc +protocol (np.sqrt(a), np.add(a, 2), np.clip(a, lo, hi)). These make a DynamicArray a drop-in +for numpy/dask arrays (used by the ome_zarr_pro backend). Distinct from operations.* function +fuzzing (test_transform_correctness) -- e.g. np.clip goes through numpy's _wrapfunc calling +a.clip(min, max, out=...), NOT the ufunc protocol, which once silently returned wrong data. +""" +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray + + +@pytest.fixture +def da_arr(): + arr = (np.random.default_rng(0).random((3, 8, 8)).astype(np.float32) * 10) - 5 + return DynamicArray(zarr.array(arr, chunks=(1, 4, 4))), arr # multi-chunk -> block reads + + +# --- array methods --- + +def test_astype_method(da_arr): + d, arr = da_arr + np.testing.assert_array_equal(d.astype("int16").compute(), arr.astype("int16")) + + +def test_clip_method(da_arr): + d, arr = da_arr + np.testing.assert_allclose(d.clip(-1, 2).compute(), np.clip(arr, -1, 2)) + np.testing.assert_allclose(d.clip(0, None).compute(), np.clip(arr, 0, None)) + + +def test_round_method(da_arr): + d, arr = da_arr + np.testing.assert_allclose(d.round().compute(), np.round(arr)) + np.testing.assert_allclose(d.round(1).compute(), np.round(arr, 1)) + + +def test_clip_round_reject_out(da_arr): + d, _ = da_arr + with pytest.raises(TypeError): + d.clip(0, 1, out=np.empty(d.shape, dtype=d.dtype)) + with pytest.raises(TypeError): + d.round(0, out=np.empty(d.shape, dtype=d.dtype)) + + +# --- numpy top-level functions that dispatch onto the array (the np.clip _wrapfunc path) --- + +def test_np_clip_and_round(da_arr): + d, arr = da_arr + np.testing.assert_allclose(np.clip(d, -1, 2).compute(), np.clip(arr, -1, 2)) # block-wise, was buggy + np.testing.assert_allclose(np.round(d).compute(), np.round(arr)) + + +# --- ufunc protocol (__array_ufunc__) --- + +@pytest.mark.parametrize("npf,ref", [ + (np.sqrt, lambda a: np.sqrt(np.abs(a))), + (np.absolute, np.abs), # alias: absolute -> abs + (np.abs, np.abs), + (np.negative, np.negative), + (np.exp, lambda a: np.exp(np.clip(a, None, 5))), + (np.sign, np.sign), + (np.floor, np.floor), + (np.ceil, np.ceil), +]) +def test_unary_ufunc_dispatch(da_arr, npf, ref): + d, arr = da_arr + # guard domain for sqrt/exp via the ref's own transform on d + got = npf(np.abs(d)).compute() if npf is np.sqrt else ( + npf(np.clip(d, None, 5)).compute() if npf is np.exp else npf(d).compute()) + np.testing.assert_allclose(got, ref(arr), rtol=1e-5) + + +def test_binary_ufunc_dispatch(da_arr): + d, arr = da_arr + np.testing.assert_allclose(np.add(d, 2).compute(), arr + 2) + np.testing.assert_allclose(np.add(2, d).compute(), 2 + arr) # scalar on left + np.testing.assert_allclose(np.multiply(d, d).compute(), arr * arr) + np.testing.assert_array_equal(np.greater(d, 0).compute(), arr > 0) + np.testing.assert_allclose(np.true_divide(d, 3).compute(), arr / 3) # alias -> divide + + +def test_unsupported_ufunc_errors_cleanly(da_arr): + """A ufunc dyna_zarr doesn't implement must NOT silently return wrong data.""" + d, _ = da_arr + with pytest.raises(TypeError): + np.sin(d).compute() + + +# --- operators (dunders) round-trip, incl. chaining --- + +def test_operators_and_chain(da_arr): + d, arr = da_arr + np.testing.assert_array_equal(((d > -1) & (d < 2)).compute(), (arr > -1) & (arr < 2)) + np.testing.assert_allclose((-d).compute(), -arr) + np.testing.assert_allclose(((d.astype("float32") + 5) / 2).clip(0, 4).compute(), + np.clip((arr + 5) / 2, 0, 4)) + + +# --- dask-compat methods used by the ome_zarr_pro backend --- + +def test_rechunk_persist_are_noops(da_arr): + d, arr = da_arr + np.testing.assert_array_equal(d.rechunk({0: d.shape[0]}).persist().compute(), arr) + np.testing.assert_array_equal(d.rechunk(-1).compute(), arr) + + +def test_map_blocks_method_dask_style(da_arr): + d, arr = da_arr + # dask-style call: func, dtype, and a dask-only meta kwarg that must be ignored + got = d.map_blocks(lambda b: b * 2, dtype="float32", meta=np.array((), dtype="float32")) + np.testing.assert_allclose(got.compute(), arr * 2) + # a bound (non-dask) kwarg is forwarded to func + got2 = d.map_blocks(lambda b, k=1.0: b + k, dtype="float32", k=3.0) + np.testing.assert_allclose(got2.compute(), arr + 3.0) + with pytest.raises(NotImplementedError): + d.map_blocks(lambda b: b, dtype="float32", drop_axis=0) + + +def test_map_overlap_method_dask_style(da_arr): + from scipy import ndimage as ndi + d, arr = da_arr + got = d.map_overlap(lambda b: ndi.uniform_filter(b, 3, mode="reflect"), + depth=1, boundary="reflect", trim=True, dtype="float32", + meta=np.array((), dtype="float32")) + np.testing.assert_allclose(got.compute(), ndi.uniform_filter(arr, 3, mode="reflect"), atol=1e-5) + with pytest.raises(NotImplementedError): + d.map_overlap(lambda b: b, depth=1, trim=False) diff --git a/tests/test_rechunk.py b/tests/test_rechunk.py new file mode 100644 index 0000000..f92a2d9 --- /dev/null +++ b/tests/test_rechunk.py @@ -0,0 +1,208 @@ +""" +Two-phase disk-staged rechunk engine (Rechunker's algorithm, dask-free) + flatten built on +top of it. Covers all grid-alignment cases (coarsen / refine / misaligned / mixed / partial), +read-once, and the flatten bridge (rechunk-to-flat-contiguous + relabel). +""" +import os +import threading +import time + +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray, io, operations as ops +from dyna_zarr.rechunk import rechunk, flatten_write, reshape_write + + +class CountZarr: + """Wrap a zarr array and count element reads (to assert read-once). Thread-safe so it's + valid under the parallel copy engine.""" + + def __init__(self, z): + self.z = z + self.reads = 0 + self._lock = threading.Lock() + + shape = property(lambda self: self.z.shape) + chunks = property(lambda self: self.z.chunks) + dtype = property(lambda self: self.z.dtype) + + def __getitem__(self, k): + b = self.z[k] + with self._lock: + self.reads += int(np.prod(b.shape)) + return b + + +@pytest.fixture +def src3d(): + arr = np.random.default_rng(0).random((20, 24, 18)).astype(np.float32) + return arr, zarr.array(arr, chunks=(8, 8, 8)) + + +@pytest.mark.parametrize("tc", [ + (16, 16, 16), # coarsen (source divides target) -> one-pass consolidate + (4, 4, 4), # refine (target divides source) -> one-pass split + (5, 7, 6), # misaligned -> two-pass via disk intermediate + (8, 6, 18), # mixed: divides on axis0, misaligned axis1, full axis2 + (20, 24, 18), # single big chunk (partial edges) +]) +def test_rechunk_matches_source(src3d, tc, tmp_path): + arr, z = src3d + out = str(tmp_path / "o.zarr") + rechunk(z, tc, out, zarr_format=2) + r = zarr.open(out, mode="r") + np.testing.assert_array_equal(r[:], arr) + assert r.chunks == tc + + +def test_rechunk_read_once(src3d, tmp_path): + arr, z = src3d + cz = CountZarr(z) + rechunk(cz, (5, 7, 6), str(tmp_path / "o.zarr"), zarr_format=2) # misaligned = two-pass + assert cz.reads == arr.size # each source element read exactly once + + +@pytest.mark.parametrize("max_workers", [1, 4]) +def test_rechunk_parallel_correct_and_read_once(src3d, max_workers, tmp_path): + """Small max_mem forces MANY regions -> the thread pool is actually exercised. Result must + match numpy and each source element must still be read exactly once under concurrency.""" + arr, z = src3d + cz = CountZarr(z) + out = str(tmp_path / "o.zarr") + rechunk(cz, (5, 7, 6), out, max_mem=4096, max_workers=max_workers, zarr_format=2) # misaligned + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr) + assert cz.reads == arr.size + + +def test_rechunk_dynamic_source(src3d, tmp_path): + arr, z = src3d + da = DynamicArray(z) + out = str(tmp_path / "o.zarr") + rechunk(da, (5, 7, 6), out, zarr_format=2) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr) + + +@pytest.mark.parametrize("shape,chunks", [ + ((12, 10, 8, 6), (5, 4, 3, 4)), # 4D non-cubic partial + ((20, 24, 18), (8, 8, 8)), # 3D cubic + ((100,), (16,)), # already 1D +]) +def test_flatten_write_matches_numpy(shape, chunks, tmp_path): + arr = np.random.default_rng(2).random(shape).astype(np.float32) + da = DynamicArray(zarr.array(arr, chunks=chunks)) + out = str(tmp_path / "f.zarr") + flatten_write(da, out, output_chunks=(64,), zarr_format=2) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr.ravel()) + + +def test_flatten_write_read_once(tmp_path): + arr = np.random.default_rng(3).random((20, 24, 18)).astype(np.float32) + cz = CountZarr(zarr.array(arr, chunks=(8, 8, 8))) + flatten_write(DynamicArray(cz), str(tmp_path / "f.zarr"), + output_chunks=(64,), max_mem=1 << 20, zarr_format=2) + assert cz.reads == arr.size # source read exactly once through the rechunk stage + + +@pytest.mark.parametrize("max_mem", [1 << 20, 4096, 512, 64]) # force split axis from 0 -> deep +def test_flatten_write_tiny_budget_deep_split(max_mem, tmp_path): + """Small budgets push the split axis past axis 0; result must still equal ravel().""" + arr = np.random.default_rng(5).random((6, 5, 4, 7)).astype(np.float32) + da = DynamicArray(zarr.array(arr, chunks=(2, 2, 2, 3))) + out = str(tmp_path / "f.zarr") + flatten_write(da, out, output_chunks=(9,), max_mem=max_mem, zarr_format=2) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr.ravel()) + + +def test_iowrite_flatten_routes_to_rechunk(tmp_path): + arr = np.random.default_rng(4).random((12, 10, 8, 6)).astype(np.float32) + da = DynamicArray(zarr.array(arr, chunks=(5, 4, 3, 4))) + out = str(tmp_path / "f.zarr") + io.write(ops.flatten(da), out, zarr_format=2, chunks=(64,)) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr.ravel()) + + +@pytest.mark.parametrize("shape,chunks,target,tchunks", [ + ((12, 10, 8, 6), (5, 4, 3, 4), (120, 48), (7, 9)), # 4D -> 2D + ((12, 10, 8, 6), (5, 4, 3, 4), (24, 10, 24), (7, 4, 10)), # 4D -> 3D + ((12, 10, 8, 6), (5, 4, 3, 4), (6, 20, 8, 6), (4, 7, 5, 4)),# 4D -> 4D + ((12, 10, 8, 6), (5, 4, 3, 4), (5760,), (100,)), # 4D -> 1D + ((20, 24, 18), (8, 8, 8), (60, 144), (16, 16)), # 3D -> 2D + ((100,), (16,), (10, 10), (3, 3)), # 1D -> 2D +]) +def test_reshape_write_matches_numpy(shape, chunks, target, tchunks, tmp_path): + arr = np.random.default_rng(6).random(shape).astype(np.float32) + da = DynamicArray(zarr.array(arr, chunks=chunks)) + out = str(tmp_path / "r.zarr") + reshape_write(da, target, out, output_chunks=tchunks, zarr_format=2) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr.reshape(target)) + + +@pytest.mark.parametrize("max_mem", [1 << 20, 4096, 256]) # force deep flatten split +def test_reshape_write_tiny_budget(max_mem, tmp_path): + arr = np.random.default_rng(7).random((6, 5, 4, 7)).astype(np.float32) + da = DynamicArray(zarr.array(arr, chunks=(2, 2, 2, 3))) + out = str(tmp_path / "r.zarr") + reshape_write(da, (24, 35), out, output_chunks=(5, 8), max_mem=max_mem, zarr_format=2) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr.reshape(24, 35)) + + +def test_iowrite_reshape_routes_to_staged(tmp_path): + arr = np.random.default_rng(8).random((12, 10, 8, 6)).astype(np.float32) + da = DynamicArray(zarr.array(arr, chunks=(5, 4, 3, 4))) + out = str(tmp_path / "r.zarr") + io.write(ops.reshape(da, (80, 72)), out, zarr_format=2, chunks=(16, 3)) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr.reshape(80, 72)) + + +@pytest.mark.skipif(os.environ.get("DYNA_LARGE") != "1", + reason="5GB flatten; set DYNA_LARGE=1 to run (slow, needs ~10GB disk)") +def test_flatten_5gb_5d_memory_bounded(tmp_path): + """Real large-data check: 5D float32 ~5GB, chunks (1,1,32,32,32). Flatten via io.write must + stay memory-bounded (peak << data size) and be correct (spot-checked, can't hold 5GB in RAM).""" + import psutil + shape = (10, 4, 256, 256, 512) # 1.34e9 elems * 4B = 5.37 GB + chunks = (1, 1, 32, 32, 32) + assert all(s % c == 0 for s, c in zip(shape[2:], chunks[2:])) + data_gb = np.prod(shape) * 4 / 1e9 + + src = str(tmp_path / "src.zarr") + z = zarr.open(src, mode="w", shape=shape, chunks=chunks, dtype="float32") + rng = np.random.default_rng(0) + for t in range(shape[0]): # fill one (z,y,x) volume at a time + for c in range(shape[1]): + z[t, c] = rng.random((shape[2], shape[3], shape[4]), dtype=np.float32) + + out = str(tmp_path / "flat.zarr") + proc = psutil.Process() + base = proc.memory_info().rss + peak = [base] + stop = [False] + + def mon(): + while not stop[0]: + peak[0] = max(peak[0], proc.memory_info().rss) + time.sleep(0.01) + + th = threading.Thread(target=mon) + th.start() + io.write(ops.flatten(DynamicArray(z)), out, zarr_format=2, chunks=(1 << 22,)) + stop[0] = True + th.join() + + peak_gb = (peak[0] - base) / 1e9 + print(f"\n5GB flatten: data {data_gb:.2f}GB peakDelta {peak_gb:.2f}GB") + + of = zarr.open(out, mode="r") + N = int(np.prod(shape)) + assert of.shape == (N,) + # spot-check correctness: random flat indices vs source at unravelled coords + idx = np.sort(rng.integers(0, N, size=500)) + coords = np.unravel_index(idx, shape) + expect = np.array([z[tuple(int(coords[d][i]) for d in range(len(shape)))] + for i in range(len(idx))], dtype=np.float32) + np.testing.assert_array_equal(of.oindex[idx], expect) + + # memory must be bounded well under the data size (budget ~256MB + overheads, not O(5GB)) + assert peak_gb < 1.5, f"flatten peak {peak_gb:.2f}GB not bounded vs {data_gb:.2f}GB data" diff --git a/tests/test_reductions.py b/tests/test_reductions.py new file mode 100644 index 0000000..97f773d --- /dev/null +++ b/tests/test_reductions.py @@ -0,0 +1,153 @@ +""" +Correctness + chunk-invariance for the streaming reduce primitive. + +A reduction must equal numpy no matter the chunking or the region driving the read, and +stay memory-bound (the reduced axis is streamed in bounded chunks). We check full compute, +random sub-slices across several chunkings, keepdims, global (axis=None), and an +end-to-end write of a projection. +""" + +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray, operations as ops + +SHAPE = (6, 8, 10) + + +def da_from(arr, chunks): + return DynamicArray(zarr.array(arr, chunks=chunks)) + + +def random_key(rng, shape): + key = [] + for size in shape: + c = rng.integers(0, 4) + if c == 0: + key.append(int(rng.integers(0, size))) + elif c == 1: + key.append(slice(None)) + elif c == 2: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b))) + else: + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b), int(rng.integers(1, 3)))) + return tuple(key) + + +# name -> (dyna_op, numpy_ref) ; each reduces to a known shape +REDUCERS = { + "sum_a0": (lambda d: ops.sum(d, 0), lambda a: a.sum(0)), + "max_a1": (lambda d: ops.max(d, 1), lambda a: a.max(1)), + "min_a2": (lambda d: ops.min(d, 2), lambda a: a.min(2)), + "mean_a0": (lambda d: ops.mean(d, 0), lambda a: a.mean(0)), + "prod_a1": (lambda d: ops.prod(d, 1), lambda a: a.prod(1)), + "sum_a02": (lambda d: ops.sum(d, (0, 2)), lambda a: a.sum(axis=(0, 2))), + "max_a0_keep": (lambda d: ops.max(d, 0, keepdims=True), lambda a: a.max(0, keepdims=True)), + "mean_a12_keep":(lambda d: ops.mean(d, (1, 2), keepdims=True), + lambda a: a.mean(axis=(1, 2), keepdims=True)), + "median_a0": (lambda d: ops.median(d, 0), lambda a: np.median(a, 0)), + "median_a12": (lambda d: ops.median(d, (1, 2)), lambda a: np.median(a, axis=(1, 2))), +} + + +@pytest.fixture(scope="module") +def arr(): + return np.random.default_rng(0).random(SHAPE).astype(np.float32) + + +@pytest.mark.parametrize("name", list(REDUCERS)) +def test_full_compute_matches_numpy(arr, name): + dyna_op, ref_op = REDUCERS[name] + got = dyna_op(da_from(arr, (2, 3, 4))).compute() + np.testing.assert_allclose(got, ref_op(arr), rtol=1e-5, atol=1e-5, + err_msg=f"{name}: full compute") + + +@pytest.mark.parametrize("chunks", [(6, 8, 10), (2, 3, 4), (1, 8, 10), (3, 1, 5)]) +def test_random_subslice_chunk_invariant(arr, chunks): + rng = np.random.default_rng(7) + failures = [] + for name, (dyna_op, ref_op) in REDUCERS.items(): + result = dyna_op(da_from(arr, chunks)) + ref = ref_op(arr) + for _ in range(15): + k = random_key(rng, ref.shape) + try: + np.testing.assert_allclose(result[k].compute(), ref[k], rtol=1e-5, atol=1e-5) + except Exception as e: + failures.append(f"{name} chunks={chunks} key={k}: " + f"{type(e).__name__}: {str(e).splitlines()[-1][:80]}") + break + assert not failures, "reduce chunk-invariance failures:\n" + "\n".join(failures) + + +def test_global_reductions(arr): + da = da_from(arr, (2, 3, 4)) + for name, npf in [("sum", np.sum), ("max", np.max), ("min", np.min), ("mean", np.mean)]: + got = getattr(ops, name)(da).compute() + assert np.ndim(got) == 0 + np.testing.assert_allclose(got, npf(arr), rtol=1e-5, atol=1e-5, err_msg=name) + + +def test_any_all(arr): + mask = (arr > 0.5) + dm = da_from(mask, (2, 3, 4)) + np.testing.assert_array_equal(ops.any(dm, 0).compute(), mask.any(0)) + np.testing.assert_array_equal(ops.all(dm, 1).compute(), mask.all(1)) + assert bool(ops.any(dm).compute()) == bool(mask.any()) + + +def test_streaming_is_memory_bounded(arr): + """A tiny strip budget forces many reduced-axis chunks but the result is unchanged + (proves the associative streaming path, independent of strip size).""" + from dyna_zarr.operations.reductions import ReduceTransform + da = da_from(arr, (2, 3, 4)) + t = ReduceTransform(da, "sum", axis=0, strip_bytes=64) # forces chunk_len == 1 + streamed = da._with_transform(t).compute() + np.testing.assert_allclose(streamed, arr.sum(0), rtol=1e-5, atol=1e-5) + + +@pytest.mark.parametrize("chunks", [(6, 8, 10), (2, 3, 4), (1, 8, 10)]) +def test_histogram_matches_numpy(arr, chunks): + """Streaming histogram == numpy exactly, across chunkings (associativity) and for + auto-range, fixed-range, and explicit-edge bins.""" + da = da_from((arr * 255).astype(np.float32), chunks) + a = (arr * 255).astype(np.float32) + # auto range + c, e = ops.histogram(da, bins=64) + cn, en = np.histogram(a, bins=64) + np.testing.assert_array_equal(c, cn) + np.testing.assert_allclose(e, en) + assert c.sum() == a.size and c.dtype == np.int64 + # fixed range + c2, _ = ops.histogram(da, bins=32, range=(0, 255)) + np.testing.assert_array_equal(c2, np.histogram(a, bins=32, range=(0, 255))[0]) + # explicit edges + per-plane slice + edges = np.linspace(0, 255, 40) + np.testing.assert_array_equal(ops.histogram(da, bins=edges)[0], np.histogram(a, bins=edges)[0]) + np.testing.assert_array_equal(da[2].histogram(64, (0, 255))[0], + np.histogram(a[2], bins=64, range=(0, 255))[0]) + + +def test_write_projection(arr, tmp_path): + """End-to-end: max-projection over axis 0 through io.write == numpy.""" + from dyna_zarr import io + da = da_from(arr, (2, 3, 4)) + out = str(tmp_path / "proj.zarr") + io.write(ops.max(da, 0), out, zarr_format=2, chunks=(4, 5)) + np.testing.assert_allclose(zarr.open(out, mode="r")[:], arr.max(0), rtol=1e-5, atol=1e-5) + + +def test_unique_matches_numpy(tmp_path): + """Streaming unique over the whole array, sorted, like numpy.unique (values only).""" + rng = np.random.default_rng(3) + lab = rng.integers(0, 12, size=(6, 20, 24)).astype("int32") + da = da_from(lab, (2, 7, 5)) # awkward chunks -> multi-region stream + np.testing.assert_array_equal(ops.unique(da), np.unique(lab)) + # float values + empty + f = rng.random((4, 8)).astype("float32") + np.testing.assert_array_equal(ops.unique(da_from(f, (1, 3))), np.unique(f)) + assert ops.unique(da_from(np.zeros((0,), "int32"), (1,))).size == 0 diff --git a/tests/test_reindex.py b/tests/test_reindex.py new file mode 100644 index 0000000..0faf070 --- /dev/null +++ b/tests/test_reindex.py @@ -0,0 +1,58 @@ +""" +Streaming reindex writer for reshape/flatten: output-chunk driven, one input chunk at a +time. Correct on any chunking (incl. non-cubic + partial chunks), hard-bounded memory, +race-free. io.write auto-routes reshape/flatten (outermost) to it. +""" +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray, io, operations as ops +from dyna_zarr.reindex import reindex_write + + +@pytest.fixture +def da_arr(): + arr = np.random.default_rng(0).random((12, 10, 8, 6)).astype(np.float32) + return DynamicArray(zarr.array(arr, chunks=(5, 4, 3, 4))), arr # non-cubic, partial + + +@pytest.mark.parametrize("out_shape,out_chunks", [ + ((120, 48), (7, 9)), # 4D -> 2D + ((24, 10, 24), (7, 4, 10)), # 4D -> 3D + ((6, 20, 8, 6), (4, 7, 5, 4)), # 4D -> 4D + ((12 * 10 * 8 * 6,), (100,)), # flatten +]) +def test_reindex_write_matches_numpy(da_arr, out_shape, out_chunks, tmp_path): + da, arr = da_arr + out = str(tmp_path / "o.zarr") + reindex_write(da, out_shape, out, out_chunks) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], arr.reshape(out_shape)) + + +def test_lru_cache_same_result(da_arr, tmp_path): + da, arr = da_arr + a = str(tmp_path / "a.zarr") + b = str(tmp_path / "b.zarr") + reindex_write(da, (120, 48), a, (7, 9), cache_size=0) + reindex_write(da, (120, 48), b, (7, 9), cache_size=8) + np.testing.assert_array_equal(zarr.open(a, mode="r")[:], zarr.open(b, mode="r")[:]) + np.testing.assert_array_equal(zarr.open(a, mode="r")[:], arr.reshape(120, 48)) + + +def test_iowrite_routes_reshape_flatten(da_arr, tmp_path): + da, arr = da_arr + r = str(tmp_path / "r.zarr") + f = str(tmp_path / "f.zarr") + io.write(ops.reshape(da, (80, 72)), r, zarr_format=2, chunks=(16, 3)) + io.write(ops.flatten(da), f, zarr_format=2, chunks=(64,)) + np.testing.assert_array_equal(zarr.open(r, mode="r")[:], arr.reshape(80, 72)) + np.testing.assert_array_equal(zarr.open(f, mode="r")[:], arr.ravel()) + + +def test_iowrite_nested_reshape_uses_normal_path(da_arr, tmp_path): + """reshape NOT outermost (abs on top) -> normal region writer, still correct.""" + da, arr = da_arr + out = str(tmp_path / "ra.zarr") + io.write(ops.abs(ops.reshape(da, (80, 72))), out, zarr_format=2, chunks=(16, 3)) + np.testing.assert_array_equal(zarr.open(out, mode="r")[:], np.abs(arr).reshape(80, 72)) diff --git a/tests/test_scan.py b/tests/test_scan.py new file mode 100644 index 0000000..744f8bd --- /dev/null +++ b/tests/test_scan.py @@ -0,0 +1,80 @@ +""" +Prefix-scan ops (cumsum/cumprod/cummax/cummin). A scan is not chunk-local: output[..p..] +depends on the whole prefix along the scan axis, so read(key) reads [0, stop) on that axis. +The sub-slice tests are the real check that the prefix math + squeeze are correct and +chunk-invariant. +""" +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray, operations as ops + +SHAPE = (5, 7, 6) +REF = { + "cumsum": lambda a, ax: np.cumsum(a, axis=ax), + "cumprod": lambda a, ax: np.cumprod(a, axis=ax), + "cummax": lambda a, ax: np.maximum.accumulate(a, axis=ax), + "cummin": lambda a, ax: np.minimum.accumulate(a, axis=ax), +} +OP = {"cumsum": ops.cumsum, "cumprod": ops.cumprod, "cummax": ops.cummax, "cummin": ops.cummin} + + +@pytest.fixture +def data(): + arr = (np.random.default_rng(0).random(SHAPE).astype(np.float32) * 4) - 2 + return arr, DynamicArray(zarr.array(arr, chunks=(2, 3, 4))) + + +@pytest.mark.parametrize("name", list(OP)) +@pytest.mark.parametrize("axis", [0, 1, 2, -1]) +def test_full_compute_matches_numpy(data, name, axis): + arr, d = data + np.testing.assert_allclose(OP[name](d, axis).compute(), REF[name](arr, axis % 3), atol=1e-4) + + +@pytest.mark.parametrize("name", list(OP)) +@pytest.mark.parametrize("axis", [0, 1, 2]) +@pytest.mark.parametrize("key", [ + np.s_[1:4, 2:6, :], np.s_[2, :, 1:5], np.s_[::2, 1:6:2, 3], np.s_[:, 0, :], np.s_[4, 6, 5], +]) +def test_subslice_chunk_invariant(data, name, axis, key): + """op(d, axis)[key] == numpy(arr, axis)[key] -- prefix read + squeeze, any chunking.""" + arr, d = data + np.testing.assert_allclose(OP[name](d, axis)[key].compute(), REF[name](arr, axis)[key], atol=1e-4) + + +def test_int_dtype_matches_numpy(data): + arr, _ = data + di = DynamicArray(zarr.array(arr.astype("int16"), chunks=(2, 3, 4))) + assert ops.cumsum(di, 0).dtype == np.cumsum(arr.astype("int16"), axis=0).dtype + + +@pytest.mark.parametrize("name", list(OP)) +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_scan_write_streams_correctly(data, name, axis, tmp_path): + """io.write routes an outermost scan to the bounded-carry streaming writer; a tiny + region budget forces multiple cross-section tiles AND multiple scan-axis strips.""" + from dyna_zarr import io + arr, d = data + out = str(tmp_path / f"{name}{axis}.zarr") + io.write(OP[name](d, axis), out, zarr_format=2, chunks=(2, 3, 4), region_size_mb=0.001) + np.testing.assert_allclose(zarr.open(out, mode="r")[:], REF[name](arr, axis), atol=1e-4) + + +def test_scan_write_multi_strip_matches(tmp_path): + """A tiny budget forces many cross-section tiles AND many scan-axis strips, so the running + carry is exercised across strip boundaries (where a naive per-block scan would be wrong).""" + from dyna_zarr import io + rng = np.random.default_rng(1) + arr = rng.random((40, 12, 10)).astype("float32") + d = DynamicArray(zarr.array(arr, chunks=(8, 5, 4))) + out = str(tmp_path / "cs.zarr") + io.write(ops.cumsum(d, 0), out, zarr_format=2, chunks=(8, 5, 4), region_size_mb=0.002) + np.testing.assert_allclose(zarr.open(out, mode="r")[:], np.cumsum(arr, axis=0), atol=1e-3) + + +def test_axis_out_of_range(data): + _, d = data + with pytest.raises(ValueError): + ops.cumsum(d, 3) diff --git a/tests/test_transform_correctness.py b/tests/test_transform_correctness.py new file mode 100644 index 0000000..ffd20d0 --- /dev/null +++ b/tests/test_transform_correctness.py @@ -0,0 +1,234 @@ +""" +Correctness fuzz harness for the lazy Transform layer. + +The risky part of dyna_zarr is the pull-based ``read(key)`` slice-translation math +inside every Transform: given an *output-space* slice, each transform must map it +back to the correct *input-space* read and return exactly what NumPy would for the +same operation followed by the same slice. This module fuzzes that contract. + +For every operation we check two things against a NumPy reference: + +1. **Full compute** ``op(da).compute()`` == ``op_np(arr)`` +2. **Random sub-slice** ``op(da)[k]`` for many random ``k`` == ``op_np(arr)[k]`` + +(2) is where the ``read(key)`` composition math lives, so most ops are fuzzed there; +ops whose ``read`` deliberately materializes the whole input (pad/tile/roll/flip/ +flatten) are still checked for correctness under sub-slicing even though they are not +memory-bound -- their memory behaviour is a *performance* concern tracked in the +non-shipped benchmark report, not a correctness bug. +""" + +import numpy as np +import pytest +import zarr + +from dyna_zarr import DynamicArray, operations + + +def _random_key_signed(rng, shape): + """Random basic-index key that INCLUDES negative starts/stops/ints (regression guard + for SliceTransform, which previously mis-sized negative slices like a[:-1]).""" + key = [] + for size in shape: + if size == 0: + key.append(slice(None)) + continue + c = rng.integers(0, 5) + if c == 0: + i = int(rng.integers(-size, size)) + key.append(i) + elif c == 1: + key.append(slice(None)) + else: + lo = int(rng.integers(-size - 1, size + 1)) + hi = int(rng.integers(-size - 1, size + 1)) + step = int(rng.integers(1, 3)) + key.append(slice(lo, hi, step)) + return tuple(key) + + +@pytest.mark.parametrize("chunks", [(4, 5, 6), (2, 2, 2), (1, 5, 6)]) +def test_slice_negative_indices(chunks): + """da[k] == arr[k] for keys with negative indices, single and composed (slice-of-slice).""" + rng = np.random.default_rng(2024) + arr = rng.random((4, 5, 6)) + da = DynamicArray(zarr.array(arr, chunks=chunks)) + for _ in range(200): + k = _random_key_signed(rng, arr.shape) + got = np.asarray(da[k].compute()) + ref = arr[k] + assert got.shape == ref.shape, f"key {k}: {got.shape} != {ref.shape}" + np.testing.assert_array_equal(got, ref, err_msg=f"key {k}") + # compose a second (also-signed) slice on top + if got.ndim: + k2 = _random_key_signed(rng, got.shape) + np.testing.assert_array_equal(np.asarray(da[k][k2].compute()), ref[k2], + err_msg=f"composed {k} then {k2}") + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + +def da_from_numpy(arr: np.ndarray) -> DynamicArray: + """Wrap a NumPy array as a DynamicArray via an in-memory zarr array.""" + z = zarr.array(arr, chunks=arr.shape) + return DynamicArray(z) + + +def random_key(rng: np.random.Generator, shape: tuple) -> tuple: + """Generate a random valid basic-indexing key for an array of ``shape``. + + Mixes integers, full slices, bounded slices, and stepped slices per axis. + """ + key = [] + for size in shape: + choice = rng.integers(0, 5) + if choice == 0: # integer index + key.append(int(rng.integers(0, size))) + elif choice == 1: # full slice + key.append(slice(None)) + elif choice == 2: # start:stop + a, b = sorted(rng.integers(0, size + 1, size=2)) + key.append(slice(int(a), int(b))) + elif choice == 3: # start:stop:step + a, b = sorted(rng.integers(0, size + 1, size=2)) + step = int(rng.integers(1, 3)) + key.append(slice(int(a), int(b), step)) + else: # open-ended + a = int(rng.integers(0, size + 1)) + key.append(slice(a, None)) + return tuple(key) + + +def assert_close(got, ref, label): + got = np.asarray(got) + ref = np.asarray(ref) + assert got.shape == ref.shape, f"{label}: shape {got.shape} != {ref.shape}" + if np.issubdtype(ref.dtype, np.floating): + np.testing.assert_allclose(got, ref, rtol=1e-6, atol=1e-6, + err_msg=f"{label}: value mismatch") + else: + np.testing.assert_array_equal(got, ref, err_msg=f"{label}: value mismatch") + + +# --------------------------------------------------------------------------- # +# Operation registry: (name, dyna_fn, numpy_fn, supports_subslice) +# +# dyna_fn / numpy_fn take the *primary* operand (DynamicArray / ndarray) and, +# for binary ops, close over a second operand built the same way. +# --------------------------------------------------------------------------- # + +BASE_SHAPE = (4, 5, 6) + + +def build_registry(arr, da, arr2, da2): + """Return list of op specs bound to concrete operands.""" + perm = (2, 0, 1) + return [ + # name, dyna_fn, numpy_fn, subslice + ("transpose", lambda: operations.transpose(da, perm), lambda: np.transpose(arr, perm), True), + ("swap_axes", lambda: operations.swap_axes(da, 0, 2), lambda: np.swapaxes(arr, 0, 2), True), + ("expand_dims", lambda: operations.expand_dims(da, 1), lambda: np.expand_dims(arr, 1), True), + ("squeeze", lambda: operations.squeeze( + operations.expand_dims(da, 1), 1), lambda: np.squeeze(np.expand_dims(arr, 1), 1), True), + ("flip", lambda: operations.flip(da, 1), lambda: np.flip(arr, 1), True), + ("roll", lambda: operations.roll(da, 2, 0), lambda: np.roll(arr, 2, 0), True), + ("pad", lambda: operations.pad(da, 1), lambda: np.pad(arr, 1), True), + ("tile", lambda: operations.tile(da, (1, 2, 1)), lambda: np.tile(arr, (1, 2, 1)), True), + ("concatenate", lambda: operations.concatenate([da, da2], 0), lambda: np.concatenate([arr, arr2], 0), True), + ("stack", lambda: operations.stack([da, da2], 0), lambda: np.stack([arr, arr2], 0), True), + ("clip", lambda: operations.clip(da, 0.2, 0.8), lambda: np.clip(arr, 0.2, 0.8), True), + ("abs", lambda: operations.abs(da), lambda: np.abs(arr), True), + ("sign", lambda: operations.sign(da), lambda: np.sign(arr), True), + ("round", lambda: operations.round(da, 2), lambda: np.round(arr, 2), True), + ("sqrt", lambda: operations.sqrt(da), lambda: np.sqrt(np.abs(arr)), True), + ("multiply", lambda: operations.multiply(da, da2), lambda: arr * arr2, True), + ("add", lambda: operations.add(da, da2), lambda: arr + arr2, True), + ("multiply_scalar", lambda: operations.multiply(da, 3.0), lambda: arr * 3.0, True), + ("where", lambda: operations.where(da, da, da2), lambda: np.where(arr, arr, arr2), True), + # --- map_blocks ufunc surface: unary (da2 is strictly positive, safe for log/recip) --- + ("negative", lambda: operations.negative(da), lambda: np.negative(arr), True), + ("square", lambda: operations.square(da), lambda: np.square(arr), True), + ("exp", lambda: operations.exp(da), lambda: np.exp(arr), True), + ("log", lambda: operations.log(da2), lambda: np.log(arr2), True), + ("log2", lambda: operations.log2(da2), lambda: np.log2(arr2), True), + ("log10", lambda: operations.log10(da2), lambda: np.log10(arr2), True), + ("floor", lambda: operations.floor(da2), lambda: np.floor(arr2), True), + ("ceil", lambda: operations.ceil(da2), lambda: np.ceil(arr2), True), + ("reciprocal", lambda: operations.reciprocal(da2), lambda: np.reciprocal(arr2), True), + ("astype_i16", lambda: operations.astype(da2, np.int16), lambda: arr2.astype(np.int16), True), + # --- binary --- + ("subtract", lambda: operations.subtract(da, da2), lambda: np.subtract(arr, arr2), True), + ("divide", lambda: operations.divide(da, da2), lambda: np.divide(arr, arr2), True), + ("floor_divide", lambda: operations.floor_divide(da, da2), lambda: np.floor_divide(arr, arr2), True), + ("mod", lambda: operations.mod(da, da2), lambda: np.mod(arr, arr2), True), + ("power", lambda: operations.power(da2, da2), lambda: np.power(arr2, arr2), True), + ("maximum", lambda: operations.maximum(da, da2), lambda: np.maximum(arr, arr2), True), + ("minimum", lambda: operations.minimum(da, da2), lambda: np.minimum(arr, arr2), True), + ("add_scalar", lambda: operations.add(da, 2.5), lambda: arr + 2.5, True), + # --- comparisons & logical (bool out) --- + ("greater", lambda: operations.greater(da, da2), lambda: np.greater(arr, arr2), True), + ("less_equal", lambda: operations.less_equal(da, da2), lambda: np.less_equal(arr, arr2), True), + ("equal", lambda: operations.equal(da, da), lambda: np.equal(arr, arr), True), + ("logical_and", lambda: operations.logical_and(da, da2), lambda: np.logical_and(arr, arr2), True), + ("logical_not", lambda: operations.logical_not(da), lambda: np.logical_not(arr), True), + # reductions: full-compute only (sub-slicing a reduced axis is a separate contract) + ("min_axis0", lambda: operations.min(da, 0), lambda: np.min(arr, 0), False), + ("max_axis0", lambda: operations.max(da, 0), lambda: np.max(arr, 0), False), + ] + + +@pytest.fixture +def operands(): + rng = np.random.default_rng(0) + arr = rng.random(BASE_SHAPE) # float64 in [0, 1) + arr[arr < 0.05] = 0.0 # a few exact zeros for sign/where + arr2 = rng.random(BASE_SHAPE) + 0.1 + return arr, da_from_numpy(arr), arr2, da_from_numpy(arr2) + + +def op_ids(reg): + return [spec[0] for spec in reg] + + +# --------------------------------------------------------------------------- # +# Tests +# --------------------------------------------------------------------------- # + +def _registry(operands): + return build_registry(*operands) + + +def test_full_compute_matches_numpy(operands): + """op(da).compute() == op_np(arr) for every operation.""" + reg = _registry(operands) + failures = [] + for name, dyna_fn, np_fn, _ in reg: + try: + assert_close(dyna_fn().compute(), np_fn(), f"{name}[full]") + except Exception as e: + failures.append(f"{name}[full]: {type(e).__name__}: {str(e).splitlines()[0]}") + assert not failures, "Full-compute mismatches:\n" + "\n".join(failures) + + +@pytest.mark.parametrize("seed", [0, 1, 7, 1234, 99999]) +def test_random_subslice_matches_numpy(operands, seed): + """op(da)[k] == op_np(arr)[k] over many random keys (the read(key) math).""" + rng = np.random.default_rng(seed) + reg = _registry(operands) + failures = [] + for name, dyna_fn, np_fn, subslice in reg: + if not subslice: + continue + ref_full = np_fn() + result = dyna_fn() + for _ in range(40): + k = random_key(rng, result.shape) + try: + got = result[k].compute() + assert_close(got, ref_full[k], f"{name}[{k}]") + except Exception as e: + failures.append(f"{name}[{k}]: {type(e).__name__}: {str(e).splitlines()[0]}") + break # one failure per op is enough signal + assert not failures, "Sub-slice mismatches:\n" + "\n".join(failures)