atlas-python 0.12.0__tar.gz → 0.13.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {atlas_python-0.12.0 → atlas_python-0.13.1}/.gitignore +2 -0
  2. {atlas_python-0.12.0 → atlas_python-0.13.1}/Cargo.lock +4 -4
  3. {atlas_python-0.12.0 → atlas_python-0.13.1}/Cargo.toml +2 -2
  4. {atlas_python-0.12.0 → atlas_python-0.13.1}/PKG-INFO +40 -2
  5. {atlas_python-0.12.0 → atlas_python-0.13.1}/README.md +58 -7
  6. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/Cargo.toml +1 -1
  7. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/README.md +39 -1
  8. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/datasets-and-arrays.md +21 -0
  9. atlas_python-0.13.1/atlas-python/docs/guides/stats.md +177 -0
  10. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/index.md +5 -0
  11. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/attr.rs +5 -5
  12. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/dataset.rs +22 -17
  13. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/error.rs +7 -3
  14. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/store.rs +182 -52
  15. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/tests/test_smoke.py +43 -0
  16. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/tests/test_xarray.py +64 -0
  17. {atlas_python-0.12.0 → atlas_python-0.13.1}/pyproject.toml +1 -1
  18. {atlas_python-0.12.0 → atlas_python-0.13.1}/python/atlas/__init__.py +2 -1
  19. {atlas_python-0.12.0 → atlas_python-0.13.1}/python/atlas/__init__.pyi +77 -1
  20. atlas_python-0.13.1/python/atlas/store.py +106 -0
  21. {atlas_python-0.12.0 → atlas_python-0.13.1}/python/atlas/xarray.py +81 -9
  22. {atlas_python-0.12.0 → atlas_python-0.13.1}/src/config.rs +43 -1
  23. atlas_python-0.13.1/src/dataset/cache.rs +54 -0
  24. atlas_python-0.13.1/src/dataset/mod.rs +11 -0
  25. atlas_python-0.13.1/src/dataset/pending_attrs.rs +244 -0
  26. atlas_python-0.12.0/src/dataset.rs → atlas_python-0.13.1/src/dataset/view.rs +54 -178
  27. {atlas_python-0.12.0 → atlas_python-0.13.1}/src/error.rs +16 -5
  28. {atlas_python-0.12.0 → atlas_python-0.13.1}/src/lib.rs +2 -0
  29. atlas_python-0.13.1/src/meta/mod.rs +21 -0
  30. atlas_python-0.13.1/src/meta/persist.rs +449 -0
  31. atlas_python-0.13.1/src/meta/schema.rs +230 -0
  32. atlas_python-0.13.1/src/meta/store_meta.rs +668 -0
  33. atlas_python-0.13.1/src/meta/type_index.rs +175 -0
  34. atlas_python-0.13.1/src/pruning/bitmap.rs +121 -0
  35. atlas_python-0.13.1/src/pruning/column.rs +414 -0
  36. atlas_python-0.13.1/src/pruning/format.rs +270 -0
  37. atlas_python-0.13.1/src/pruning/io.rs +266 -0
  38. atlas_python-0.13.1/src/pruning/mod.rs +463 -0
  39. atlas_python-0.13.1/src/pruning/value.rs +129 -0
  40. atlas_python-0.13.1/src/schema/array.rs +43 -0
  41. atlas_python-0.13.1/src/schema/attr.rs +252 -0
  42. atlas_python-0.13.1/src/schema/dtype.rs +277 -0
  43. atlas_python-0.13.1/src/schema/mod.rs +17 -0
  44. atlas_python-0.13.1/src/store/bulk_read.rs +228 -0
  45. atlas_python-0.13.1/src/store/durability.rs +282 -0
  46. atlas_python-0.12.0/src/store.rs → atlas_python-0.13.1/src/store/mod.rs +119 -366
  47. atlas_python-0.13.1/tests/integration.rs +1518 -0
  48. atlas_python-0.12.0/atlas-python/docs/guides/stats.md +0 -92
  49. atlas_python-0.12.0/src/meta.rs +0 -745
  50. atlas_python-0.12.0/src/schema.rs +0 -436
  51. atlas_python-0.12.0/tests/integration.rs +0 -746
  52. {atlas_python-0.12.0 → atlas_python-0.13.1}/.github/workflows/atlas-python-docs.yaml +0 -0
  53. {atlas_python-0.12.0 → atlas_python-0.13.1}/.github/workflows/atlas-python-release.yaml +0 -0
  54. {atlas_python-0.12.0 → atlas_python-0.13.1}/.github/workflows/atlas-rust-release.yaml +0 -0
  55. {atlas_python-0.12.0 → atlas_python-0.13.1}/.github/workflows/ci.yaml +0 -0
  56. {atlas_python-0.12.0 → atlas_python-0.13.1}/CONTRIBUTING.md +0 -0
  57. {atlas_python-0.12.0 → atlas_python-0.13.1}/LICENSE +0 -0
  58. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/.python-version +0 -0
  59. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/LICENSE +0 -0
  60. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/benchmarks/README.md +0 -0
  61. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/benchmarks/_common.py +0 -0
  62. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/benchmarks/bench_collection.py +0 -0
  63. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/benchmarks/generate_bench_charts.py +0 -0
  64. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/assets/bench_gridded.svg +0 -0
  65. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/assets/bench_profile.svg +0 -0
  66. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/benchmarks.md +0 -0
  67. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/examples.md +0 -0
  68. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/attributes.md +0 -0
  69. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/bulk-reads.md +0 -0
  70. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/cloud-storage.md +0 -0
  71. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/codecs-and-meta.md +0 -0
  72. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/dask.md +0 -0
  73. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/dtypes.md +0 -0
  74. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/durability.md +0 -0
  75. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/shared-arrays.md +0 -0
  76. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/guides/xarray.md +0 -0
  77. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/installation.md +0 -0
  78. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/quickstart.md +0 -0
  79. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/reference/atlas.md +0 -0
  80. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/reference/dataset-view.md +0 -0
  81. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/reference/xarray-accessor.md +0 -0
  82. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
  83. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/01_basics.py +0 -0
  84. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/02_xarray.py +0 -0
  85. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/03_dask_streaming.py +0 -0
  86. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/04_meta_formats.py +0 -0
  87. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/05_codecs.py +0 -0
  88. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/06_stats_scan.py +0 -0
  89. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/07_shared_arrays.py +0 -0
  90. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/08_object_store.py +0 -0
  91. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/examples/09_missing_data.py +0 -0
  92. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/mkdocs.yml +0 -0
  93. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/dtype.rs +0 -0
  94. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/lib.rs +0 -0
  95. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/logging.rs +0 -0
  96. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/src/runtime.rs +0 -0
  97. {atlas_python-0.12.0 → atlas_python-0.13.1}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  98. {atlas_python-0.12.0 → atlas_python-0.13.1}/examples/lifecycle.rs +0 -0
  99. {atlas_python-0.12.0 → atlas_python-0.13.1}/examples/sensor_fleet.rs +0 -0
  100. {atlas_python-0.12.0 → atlas_python-0.13.1}/examples/weather_store.rs +0 -0
  101. {atlas_python-0.12.0 → atlas_python-0.13.1}/python/atlas/py.typed +0 -0
  102. {atlas_python-0.12.0 → atlas_python-0.13.1}/src/array.rs +0 -0
@@ -33,3 +33,5 @@ atlas-python/python/atlas/_atlas.pyd
33
33
  atlas-python/python/atlas/_atlas.abi3.so
34
34
  atlas-python/python/atlas/_atlas*.so
35
35
  atlas-python/python/atlas/_atlas.pdb
36
+ test-collection-netcdf-seadatanet/
37
+ batch_to_atlas.py
@@ -28,9 +28,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
28
28
 
29
29
  [[package]]
30
30
  name = "array-format"
31
- version = "0.11.0"
31
+ version = "0.12.0"
32
32
  source = "registry+https://github.com/rust-lang/crates.io-index"
33
- checksum = "f894cd59132aa6a00c360fef55469100c39da19c556bbf44a46bd647f7252fae"
33
+ checksum = "f7379c3303a9b9f0693ccd040d85da31e302a601efeafae8a6cb69341e42de41"
34
34
  dependencies = [
35
35
  "bytes",
36
36
  "futures",
@@ -71,7 +71,7 @@ dependencies = [
71
71
 
72
72
  [[package]]
73
73
  name = "atlas-python"
74
- version = "0.12.0"
74
+ version = "0.13.1"
75
75
  dependencies = [
76
76
  "atlas-rust",
77
77
  "ndarray",
@@ -86,7 +86,7 @@ dependencies = [
86
86
 
87
87
  [[package]]
88
88
  name = "atlas-rust"
89
- version = "0.12.0"
89
+ version = "0.13.1"
90
90
  dependencies = [
91
91
  "array-format",
92
92
  "chrono",
@@ -5,7 +5,7 @@ members = ["atlas-python"]
5
5
  # Registry name on crates.io (`atlas` is taken). The library is still
6
6
  # imported as `atlas` — see `[lib]` below.
7
7
  name = "atlas-rust"
8
- version = "0.12.0"
8
+ version = "0.13.1"
9
9
  edition = "2024"
10
10
  description = "Directory-based store for thousands of N-dimensional datasets local or remote using object storage."
11
11
  license = "Apache-2.0"
@@ -22,7 +22,7 @@ all-features = true
22
22
  rustdoc-args = ["--cfg", "docsrs"]
23
23
 
24
24
  [dependencies]
25
- array-format = "0.11.0"
25
+ array-format = "0.12.0"
26
26
  object_store = "0.13"
27
27
  serde = { version = "1", features = ["derive"] }
28
28
  serde_json = "1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlas-python
3
- Version: 0.12.0
3
+ Version: 0.13.1
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Programming Language :: Python :: 3
@@ -220,6 +220,40 @@ for the full resolution order.
220
220
  - `bool` is available as an *attribute* type but not as an array dtype.
221
221
  - `binary`, `list[...]`, `fixed_size_list[...,N]` are reserved for a later release.
222
222
 
223
+ ## Cross-dataset pruning
224
+
225
+ `ds.array_stats(name)` answers "what's the range of `temperature` in *this* dataset?" one at a
226
+ time. To ask "which of my 10 000 datasets could contain a value above 25?" as a single
227
+ vectorised scan, use the **pruning index** — a flattened, columnar view of the per-dataset
228
+ statistics, one row per dataset:
229
+
230
+ ```python
231
+ import numpy as np
232
+
233
+ store = atlas.Atlas.open("/tmp/my_store")
234
+
235
+ # Footer only — every column's collection-wide min/max, no column data fetched.
236
+ summ = store.column_summaries()
237
+ if summ["temperature"]["max"] < 25.0:
238
+ ... # nothing can match — skip the column entirely
239
+
240
+ # Fetch just the columns you need; each is a dict of numpy arrays over the row space.
241
+ idx = store.pruning_index(arrays=["temperature"])
242
+ col = idx["columns"]["temperature"]
243
+
244
+ ok = col["present"] & col["stats_valid"] & idx["live"] # exclude gaps + deleted rows
245
+ hits = np.where(ok & (col["max"] > 25.0))[0]
246
+ candidates = [idx["datasets"][i] for i in hits] # row -> dataset name
247
+ ```
248
+
249
+ The return value is self-describing: `idx["datasets"][i]` names row `i`, `idx["live"]` is the
250
+ delete mask (always `&` it in), and datasets that don't declare a column are explicit gaps
251
+ (`present` is `False`, with `row_count` 0). Only the columns you ask for are fetched from
252
+ storage, so the cost is independent of how many other columns the collection has. `min`/`max`
253
+ keep their source type (strings come back as `bytes`, timestamps as int64 nanoseconds). See the
254
+ [stats & scans guide](https://github.com/maris-development/atlas/blob/main/atlas-python/docs/guides/stats.md)
255
+ for the full API, including `global_attrs=` / `array_attrs=` columns.
256
+
223
257
  ## Cloud / object storage
224
258
 
225
259
  With the `cloud` extra, `Atlas.open` / `Atlas.create` accept an
@@ -244,9 +278,13 @@ handle instead of a local path. The path-based local-filesystem API works withou
244
278
  | `add_xarray_dataset(ds, name, chunks=None, fill_value=None)` | Append an `xarray.Dataset` (does **not** flush). `fill_value` overrides the per-array fill (scalar or `{var: scalar}`); see [Missing data](#missing-data). |
245
279
  | `open_as_xarray_dataset(name) -> xr.Dataset` | Read a dataset back (chunked vars come back dask-backed). |
246
280
  | `open_as_many_xarray_dataset(names, concat_dim="dataset") -> xr.Dataset` | Open many datasets stacked along `concat_dim` (eager numpy). |
281
+ | `pruning_index(arrays=None, global_attrs=None, array_attrs=None) -> dict` | Flattened statistics for **only** the requested columns; see [Cross-dataset pruning](#cross-dataset-pruning). |
282
+ | `column_summaries() -> dict` | Every column's collection-wide min/max, read from the index footer alone (no column data fetched). |
283
+ | `dataset_row(name) -> int \| None` | The dataset's fixed row ordinal in the pruning index. |
284
+ | `row_slots() -> int` | Total row slots (live + tombstoned) — the pruning index's height. |
247
285
  | `flush()` | The single durability boundary — persist everything. |
248
286
  | `close()` | Alias for `flush()`; also the `with`-block exit. |
249
- | `compact()` | Reclaim tombstoned space across cached array files. |
287
+ | `compact()` | Reclaim tombstoned space across cached array files, and renumber row ordinals. |
250
288
  | `__enter__` / `__exit__` | Context-manager support (`__exit__` calls `close()`). |
251
289
 
252
290
  ### `atlas.DatasetView`
@@ -26,6 +26,7 @@ Each dataset is a named group of N-dimensional arrays with typed attributes —
26
26
  ```text
27
27
  my_store/
28
28
  ├── atlas.json ← interned per-dataset schema + attribute-key namespace (JSON)
29
+ ├── pruning.idx ← columnar min/max/count index across all datasets (see below)
29
30
  ├── _global/
30
31
  │ └── data.af ← dataset-level (global) attribute values, one entry per dataset
31
32
  ├── temperature/
@@ -36,7 +37,7 @@ my_store/
36
37
  └── data.af
37
38
  ```
38
39
 
39
- `atlas.json` holds only the **schema** of the collection. Attribute **values** live in the `.af` files: per-variable attributes on the variable's own file, and dataset-level attributes in the reserved `_global` file.
40
+ `atlas.json` holds only the **schema** of the collection. Attribute **values** live in the `.af` files: per-variable attributes on the variable's own file, and dataset-level attributes in the reserved `_global` file. `pruning.idx` is a derived, columnar statistics index — everything you need to prune a query to a handful of candidate datasets without opening a single `.af` file.
40
41
 
41
42
  ---
42
43
 
@@ -44,7 +45,7 @@ my_store/
44
45
 
45
46
  `atlas.json` is read **once** when the store is opened or created. Every subsequent mutation — `create_dataset`, `define_array`, `set_attribute`, `set_array_attribute`, `delete_array`, `delete_dataset` — only touches in-memory state; array writes buffer inside the per-array in-memory layer and attribute writes buffer in a pending-attribute map. **Nothing reaches disk until `Atlas::flush()` (or `Atlas::close()`).** Dropping an `Atlas` without flushing abandons every pending in-memory write.
46
47
 
47
- A single `Atlas::flush()` drains the buffered attributes into their `.af` files, walks every cached `ArrayFile` (writing deltas + stats), and then serialises the in-memory schema to `atlas.json`. This gives one durability boundary for the whole store: N datasets ⇒ one delta file per touched array name (not one per dataset) and one `atlas.json` rewrite (not N).
48
+ A single `Atlas::flush()` drains the buffered attributes into their `.af` files, walks every cached `ArrayFile` (writing deltas + stats), fills and writes the `pruning.idx` statistics index, and then serialises the in-memory schema to `atlas.json`. This gives one durability boundary for the whole store: N datasets ⇒ one delta file per touched array name (not one per dataset) and one `atlas.json` rewrite (not N).
48
49
 
49
50
  `DatasetView` is a borrowed handle into the atlas's shared meta — it has no `flush()` of its own.
50
51
 
@@ -56,7 +57,7 @@ A single `Atlas::flush()` drains the buffered attributes into their `.af` files,
56
57
 
57
58
  The schema is a plain JSON file written on `Atlas::flush()` / `Atlas::close()`. It stores:
58
59
 
59
- - **Store version** — for format upgrades. The current version is `2`; stores written by an older atlas are rejected on open (re-export to upgrade).
60
+ - **Store version** — for format upgrades. The current version is `3`; stores written by an older atlas are rejected on open with a clear `Error::UnsupportedVersion` (re-export to upgrade). Version 3 added the per-dataset **row ordinals + tombstone mask** that back the pruning index.
60
61
  - **Interned schema pool** — each *distinct* per-dataset schema is stored once and referenced by index. A dataset's schema is its array schemas (per array: dtype, shape, chunk shape, named dimensions, codec) plus its **attribute-key namespace** (the names of its global and per-variable attribute keys). A collection of thousands of homogeneous datasets stores its schema a single time.
61
62
  - **Dataset registry** — a map of dataset name → schema-pool index.
62
63
  - **Merged schema** — a collection-wide summary listing every unique array (with its dtype widened across all datasets, its dimensions, and its per-variable attribute types) and every global attribute type. Descriptive only — reads always use each dataset's own schema — but handy for tools that want one view of "what's in here." Also exposed programmatically via `Atlas::merged_schema()`.
@@ -86,6 +87,52 @@ Each array variable gets its own subdirectory with a single `data.af` binary fil
86
87
  - **Persisted statistics** — on `Atlas::flush()`, min, max, null count, and row count are computed per array per dataset and stored alongside the data. Cells equal to the array's fill value are tallied in `null_count` and excluded from `min`/`max` (NaN fills match NaN cells by bit pattern). Statistics survive store reopening.
87
88
  - **In-memory caches** — a 256 MiB decoded block cache and a 64 MiB raw I/O cache sit in front of the object store for repeated reads.
88
89
 
90
+ ### `pruning.idx`
91
+
92
+ The per-array `.af` stats above answer "what's the min/max of `temperature` in *this* dataset?" one dataset at a time. `pruning.idx` answers the **cross-dataset** question — "which of my 10 000 datasets could possibly contain a value above 25?" — as a single vectorised scan, without opening any `.af` file.
93
+
94
+ It is a flattened, **columnar** index, written at the store root on every `flush()`:
95
+
96
+ - **One logical row per dataset**, positional — row *i* is the dataset at ordinal *i*. Deleted datasets keep their slot (see [Deletes](#deletes-and-compaction) below) and are hidden by a liveness mask, so a row number never changes underneath a cached index.
97
+ - **One column per array, per global attribute, and per (array, attribute) pair.** Each column holds, for every dataset: a `present` bit, a `stats_valid` bit, `min`, `max`, `row_count`, and `null_count`. Datasets that don't declare that array/attribute are **explicit gaps** (`present = false`), so columns stay aligned no matter how heterogeneous the collection is.
98
+ - **Compacted and column-addressable.** Sparsity is the norm (~26 % density on a real 10 000-dataset collection), so on disk each column stores values only for its present rows, keyed by the bitmap. Columns are compressed individually (Zstd by default, via `StoreConfig::pruning_compression`) and located through a footer directory — reading 2 of 200 columns fetches ~2/200ths of the file via ranged reads, never the whole thing.
99
+ - **Guarded against staleness.** The footer carries a `meta_epoch` that must match `atlas.json`; an index left behind by a crash between the two writes is rejected (`Error::CorruptIndex`) rather than silently mis-attributing rows.
100
+
101
+ The read API is a two-step funnel — rule columns out from the footer alone, then fetch only the survivors:
102
+
103
+ ```rust
104
+ use atlas::{ColumnKey, StatVal};
105
+
106
+ // Footer only — every column's collection-wide min/max, no column data fetched.
107
+ for (key, summary) in store.column_summaries().await? {
108
+ // e.g. skip a column whose global max can't satisfy the predicate.
109
+ let _ = summary.might_match(|_, hi| hi > &StatVal::Float(25.0));
110
+ }
111
+
112
+ // Fetch just the columns you need. The returned index is self-describing:
113
+ // it carries the liveness mask and the row↔name mapping.
114
+ let key = ColumnKey::array("temperature");
115
+ let index = store.pruning_index(std::slice::from_ref(&key)).await?;
116
+ if let Some(view) = index.view(&key) {
117
+ // Deleted and absent rows are already excluded.
118
+ for row in view.candidates(|_, hi| hi > &StatVal::Float(25.0)) {
119
+ println!("candidate: {}", index.dataset_name(row).unwrap());
120
+ }
121
+ }
122
+ ```
123
+
124
+ `min`/`max` keep the type the statistic was computed with (string min/max are lexicographic bytes; timestamps are integer nanoseconds); a column's collection-wide *declared* type is the merged dtype from `atlas.json`. **Attribute columns currently record presence only** — which datasets carry the key — not attribute values; filtering on an attribute's *value* through the index isn't wired up yet.
125
+
126
+ ---
127
+
128
+ ## Deletes and compaction
129
+
130
+ `delete_dataset` and `delete_array` are **logical**. A deleted dataset is *tombstoned* — its slot stays in `atlas.json` (so every later dataset keeps its row ordinal) and a liveness bit is cleared; its entries in the shared `.af` files are marked deleted but the bytes remain. Reads, `list_datasets`, the merged schema, and the pruning index all skip tombstoned datasets, so a delete is invisible to consumers immediately, but nothing is reclaimed yet.
131
+
132
+ `Atlas::compact()` is the garbage collector. It rewrites every cached `.af` file with the tombstoned regions dropped, **renumbers** the surviving datasets to close the ordinal holes, and rebuilds the pruning index against the new numbering. It is the only operation that changes a dataset's row ordinal — so a row number cached from before a compact is stale afterwards.
133
+
134
+ Re-creating a deleted name reuses its slot (and pruning-index row), starting from a clean schema — the revived dataset never inherits the previous occupant's stats.
135
+
89
136
  ---
90
137
 
91
138
  ## Quick start
@@ -149,8 +196,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
149
196
  | **Array** | An N-dimensional typed array with named dimensions and an optional chunk shape. |
150
197
  | **Attribute** | A typed scalar attached to a dataset (metadata, not array data). |
151
198
  | **Array file** | One `data.af` file per variable name, shared across all datasets that define that variable. |
152
- | **Flush** | `Atlas::flush()` — the single durability boundary. Persists every cached array file and rewrites `atlas.json` from the in-memory `StoreMeta`. Must be called explicitly (or via `Atlas::close()` / Python's `with atlas:`). |
153
- | **Compact** | `Atlas::compact()` rewrites every cached `.af` file to reclaim space after deletes. |
199
+ | **Pruning index** | `pruning.idx` — a columnar min/max/count index across all datasets. Query it with `Atlas::pruning_index(&[ColumnKey])` / `column_summaries()` to prune to candidate datasets without reading array data. |
200
+ | **Flush** | `Atlas::flush()` the single durability boundary. Persists every cached array file, the pruning index, and rewrites `atlas.json` from the in-memory `StoreMeta`. Must be called explicitly (or via `Atlas::close()` / Python's `with atlas:`). |
201
+ | **Compact** | `Atlas::compact()` rewrites every cached `.af` file to reclaim space after deletes, and renumbers dataset row ordinals. |
154
202
  | **StoreConfig** | Configuration passed to `Atlas::create`. Currently holds the compression `Codec`. |
155
203
  | **Codec** | Compression codec for new array blocks: `Codec::Zstd` (default), `Codec::Lz4`, or `Codec::Uncompressed`. Persisted in `atlas.json`; `open` reads it automatically. |
156
204
 
@@ -215,6 +263,7 @@ Reading `temperature` for 1 000 datasets means opening exactly **one file**. Thi
215
263
  | Cross-dataset column scan | Slow (N file opens) | Slow (N directory opens) | Fast (1 file open) |
216
264
  | Partial reads | Yes | Yes | Yes |
217
265
  | Statistics (min/max/nulls) | No | No | Yes (persisted on flush) |
266
+ | Cross-dataset stat pruning | No | No | Yes (`pruning.idx`, vectorised) |
218
267
  | Self-describing metadata | Yes | Yes | Yes (`atlas.json`) |
219
268
  | Language support | C/Python/Julia/… | Python/Java/… | Rust |
220
269
  | Mutable after write | Limited | Yes | Yes (chunked overwrites + compact) |
@@ -262,10 +311,12 @@ Two caches sit in front of the object store:
262
311
 
263
312
  The decoded cache means repeated reads of the same chunk cost only a hash-map lookup. Both caches are shared across all `DatasetView`s that open the same `Atlas`.
264
313
 
265
- ### Persisted statistics
314
+ ### Persisted statistics and pruning
266
315
 
267
316
  Min, max, null count, and row count are computed and persisted on every `Atlas::flush()`. Downstream systems can read these statistics from the opened `DatasetView` without touching array data at all — useful for query planning, dashboards, or data-quality checks.
268
317
 
318
+ For **cross-dataset** query planning, the [`pruning.idx`](#pruningidx) index turns "which datasets could match?" into a vectorised scan over compacted, column-addressed statistics. A footer-only `column_summaries()` rules whole columns out for free; `pruning_index(&[…])` then fetches just the requested columns via ranged reads. On a real 10 000-dataset collection the whole index is ~0.3 MiB compressed, and a single-column query reads it in a couple of milliseconds — so a query engine can prune a fleet of datasets to a handful of candidates before opening a single `.af` file.
319
+
269
320
  ### Compression
270
321
 
271
322
  The codec is chosen once at store creation via `StoreConfig` and written into `atlas.json`. `Atlas::open` reads it from there, so callers never need to repeat the codec choice. Each array also records its own codec in `atlas.json`, so a store can theoretically hold arrays written with different codecs if the schema is migrated.
@@ -280,7 +331,7 @@ Choose LZ4 when decompression throughput matters more than storage size (e.g. la
280
331
 
281
332
  ### Write path
282
333
 
283
- Writes are buffered in-memory across the whole atlas. Calling `Atlas::flush()` compresses and writes every modified block across every cached array file, then rewrites `atlas.json` atomically (a single `PUT`). The write path scales with the number of modified chunks, not the number of datasets, and N consecutive `add_xarray_dataset` / `create_dataset` calls amortise to a single flush.
334
+ Writes are buffered in-memory across the whole atlas. Attribute writes buffer in a shared, **interned** pending map — identical values (a NetCDF collection is ~94 % duplicate attribute values) are stored once and shared, so the buffer stays small during a large ingest. Calling `Atlas::flush()` compresses and writes every modified block across every cached array file, writes the pruning index, then rewrites `atlas.json` atomically (a single `PUT`). The write path scales with the number of modified chunks, not the number of datasets, and N consecutive `add_xarray_dataset` / `create_dataset` calls amortise to a single flush.
284
335
 
285
336
  ### Compaction
286
337
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "atlas-python"
3
- version = "0.12.0"
3
+ version = "0.13.1"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -188,6 +188,40 @@ for the full resolution order.
188
188
  - `bool` is available as an *attribute* type but not as an array dtype.
189
189
  - `binary`, `list[...]`, `fixed_size_list[...,N]` are reserved for a later release.
190
190
 
191
+ ## Cross-dataset pruning
192
+
193
+ `ds.array_stats(name)` answers "what's the range of `temperature` in *this* dataset?" one at a
194
+ time. To ask "which of my 10 000 datasets could contain a value above 25?" as a single
195
+ vectorised scan, use the **pruning index** — a flattened, columnar view of the per-dataset
196
+ statistics, one row per dataset:
197
+
198
+ ```python
199
+ import numpy as np
200
+
201
+ store = atlas.Atlas.open("/tmp/my_store")
202
+
203
+ # Footer only — every column's collection-wide min/max, no column data fetched.
204
+ summ = store.column_summaries()
205
+ if summ["temperature"]["max"] < 25.0:
206
+ ... # nothing can match — skip the column entirely
207
+
208
+ # Fetch just the columns you need; each is a dict of numpy arrays over the row space.
209
+ idx = store.pruning_index(arrays=["temperature"])
210
+ col = idx["columns"]["temperature"]
211
+
212
+ ok = col["present"] & col["stats_valid"] & idx["live"] # exclude gaps + deleted rows
213
+ hits = np.where(ok & (col["max"] > 25.0))[0]
214
+ candidates = [idx["datasets"][i] for i in hits] # row -> dataset name
215
+ ```
216
+
217
+ The return value is self-describing: `idx["datasets"][i]` names row `i`, `idx["live"]` is the
218
+ delete mask (always `&` it in), and datasets that don't declare a column are explicit gaps
219
+ (`present` is `False`, with `row_count` 0). Only the columns you ask for are fetched from
220
+ storage, so the cost is independent of how many other columns the collection has. `min`/`max`
221
+ keep their source type (strings come back as `bytes`, timestamps as int64 nanoseconds). See the
222
+ [stats & scans guide](https://github.com/maris-development/atlas/blob/main/atlas-python/docs/guides/stats.md)
223
+ for the full API, including `global_attrs=` / `array_attrs=` columns.
224
+
191
225
  ## Cloud / object storage
192
226
 
193
227
  With the `cloud` extra, `Atlas.open` / `Atlas.create` accept an
@@ -212,9 +246,13 @@ handle instead of a local path. The path-based local-filesystem API works withou
212
246
  | `add_xarray_dataset(ds, name, chunks=None, fill_value=None)` | Append an `xarray.Dataset` (does **not** flush). `fill_value` overrides the per-array fill (scalar or `{var: scalar}`); see [Missing data](#missing-data). |
213
247
  | `open_as_xarray_dataset(name) -> xr.Dataset` | Read a dataset back (chunked vars come back dask-backed). |
214
248
  | `open_as_many_xarray_dataset(names, concat_dim="dataset") -> xr.Dataset` | Open many datasets stacked along `concat_dim` (eager numpy). |
249
+ | `pruning_index(arrays=None, global_attrs=None, array_attrs=None) -> dict` | Flattened statistics for **only** the requested columns; see [Cross-dataset pruning](#cross-dataset-pruning). |
250
+ | `column_summaries() -> dict` | Every column's collection-wide min/max, read from the index footer alone (no column data fetched). |
251
+ | `dataset_row(name) -> int \| None` | The dataset's fixed row ordinal in the pruning index. |
252
+ | `row_slots() -> int` | Total row slots (live + tombstoned) — the pruning index's height. |
215
253
  | `flush()` | The single durability boundary — persist everything. |
216
254
  | `close()` | Alias for `flush()`; also the `with`-block exit. |
217
- | `compact()` | Reclaim tombstoned space across cached array files. |
255
+ | `compact()` | Reclaim tombstoned space across cached array files, and renumber row ordinals. |
218
256
  | `__enter__` / `__exit__` | Context-manager support (`__exit__` calls `close()`). |
219
257
 
220
258
  ### `atlas.DatasetView`
@@ -56,6 +56,27 @@ jan = atlas.open_dataset("jan_2024")
56
56
  atlas.delete_dataset("feb_2024")
57
57
  ```
58
58
 
59
+ ## Deleting datasets
60
+
61
+ `delete_dataset` is **logical**. The dataset immediately disappears from
62
+ `list_datasets()`, reads, the merged schema, and the pruning index — but under
63
+ the hood it is *tombstoned*: its slot stays in `atlas.json` and its bytes stay
64
+ in the shared array files. This keeps every other dataset's **row ordinal
65
+ stable** (`dataset_row(name)` never shifts under you, which is what lets a
66
+ cached [pruning index](stats.md) stay valid across a delete).
67
+
68
+ ```python
69
+ atlas.delete_dataset("feb_2024")
70
+ atlas.dataset_exists("feb_2024") # False — invisible immediately
71
+ atlas.row_slots() # still counts the dead slot until compact
72
+ ```
73
+
74
+ Call [`atlas.compact()`](durability.md) to actually reclaim the space: it
75
+ rewrites the array files without the tombstoned regions and **renumbers** the
76
+ surviving datasets to close the ordinal holes — the only operation that changes
77
+ a row ordinal. Re-creating a deleted name reuses its slot with a clean schema
78
+ (the revived dataset never inherits the old one's data or stats).
79
+
59
80
  ## Declaring an array
60
81
 
61
82
  `define_array` records the schema (dtype, dims, shape, chunking, fill
@@ -0,0 +1,177 @@
1
+ # Stats and scans
2
+
3
+ Atlas computes and persists per-array summary statistics on every flush.
4
+ You can scan thousands of datasets without reading any raw chunks.
5
+
6
+ ## What's tracked
7
+
8
+ `view.array_stats(name)` returns a dict (or `None` if the array doesn't
9
+ exist in this dataset, or hasn't been flushed yet):
10
+
11
+ | Key | Meaning |
12
+ |---|---|
13
+ | `row_count` | The array's full logical size — the product of its shape. |
14
+ | `null_count` | Cells equal to the `fill_value`, **plus every cell never written**. |
15
+ | `min` | Minimum across written, non-fill cells. |
16
+ | `max` | Maximum across written, non-fill cells. |
17
+
18
+ `row_count` is capacity, not how much you wrote. An array declared with shape
19
+ `[10]` reports `row_count == 10` even if you only ever wrote 5 cells — the
20
+ other 5 show up in `null_count`, since they read back as the fill value:
21
+
22
+ ```python
23
+ ds.define_array("t", dtype="int32", dims=["i"], shape=[10], chunk_shape=[5], fill_value=0)
24
+ ds.write_array("t", start=[0], data=np.array([1, 2, 3, 4, 5], dtype=np.int32))
25
+ atlas.flush()
26
+ ds.array_stats("t")
27
+ # {"row_count": 10, "null_count": 5, "min": 1, "max": 5}
28
+ ```
29
+
30
+ So `row_count - null_count` is the count of real values.
31
+
32
+ Stats are populated **after** [`atlas.flush()`](durability.md). Between a
33
+ `define_array` / `write_array` and the next flush, `array_stats(name)`
34
+ returns `None`.
35
+
36
+ ```python
37
+ ds.write_array("readings", start=[0], data=values)
38
+ ds.array_stats("readings") # None — not flushed yet
39
+ atlas.flush()
40
+ ds.array_stats("readings") # {"row_count": ..., "null_count": ..., "min": ..., "max": ...}
41
+ ```
42
+
43
+ Stats live in a per-array `{array}/data.stats` sidecar, next to that array's
44
+ `data.af` — **not** in `atlas.json`, which holds only schemas.
45
+
46
+ ## Cross-dataset scans: the pruning index
47
+
48
+ `array_stats` answers for one array in one dataset. To ask "which datasets
49
+ could possibly match this?" across a whole collection, use the **pruning
50
+ index** — a flattened, columnar view with one row per dataset:
51
+
52
+ ```python
53
+ store = atlas.Atlas.open("/tmp/store")
54
+
55
+ idx = store.pruning_index(arrays=["readings"])
56
+ col = idx["columns"]["readings"]
57
+
58
+ # Vectorized: no Python loop over datasets.
59
+ ok = col["present"] & col["stats_valid"] & idx["live"]
60
+ hits = np.where(ok & (col["max"] > 25.0))[0]
61
+ candidates = [idx["datasets"][i] for i in hits]
62
+ ```
63
+
64
+ Each column is a dict of numpy arrays, all the same length as `idx["rows"]`:
65
+
66
+ | Key | Meaning |
67
+ |---|---|
68
+ | `present` | The dataset at this row declares the array/attribute. |
69
+ | `stats_valid` | `min`/`max` are meaningful here. |
70
+ | `min`, `max` | The per-dataset range. |
71
+ | `row_count`, `null_count` | As above — **both 0** where `present` is `False`. |
72
+
73
+ Row `i` is the dataset at ordinal `i` (`store.dataset_row(name)`), and
74
+ `idx["datasets"][i]` maps back to its name. Datasets that don't declare the
75
+ array are **explicit gaps** rather than missing entries, so columns stay
76
+ aligned across the collection no matter how heterogeneous it is. A dataset
77
+ without the array contributes `row_count == 0`, which is a real answer you can
78
+ sum or filter on directly.
79
+
80
+ `present` and `stats_valid` are separate on purpose: `stats_valid` is `False`
81
+ where there is no usable range even though the dataset does declare the array
82
+ — `List` dtypes, which carry no statistics at all, and rows written but not
83
+ yet flushed. Their `row_count` / `null_count` are still valid, so check
84
+ `present` for "does it have this?" and `stats_valid` before touching
85
+ `min`/`max`.
86
+
87
+ Deleted datasets keep their row and are masked by `idx["live"]`. Always `&` it
88
+ in, or a deleted dataset's values will widen your result.
89
+
90
+ ### Read only what you need
91
+
92
+ `pruning.idx` is column-addressed: asking for two columns fetches two byte
93
+ ranges, not the file. On a 10 000-dataset collection with 781 columns, reading
94
+ one column takes ~2 ms.
95
+
96
+ Cheaper still, `column_summaries()` reads the **footer only** — every column's
97
+ collection-wide min/max and present count, no column data at all:
98
+
99
+ ```python
100
+ summaries = store.column_summaries() # ~2 ms for 781 columns
101
+ if summaries["readings"]["max"] < 25.0:
102
+ ... # no dataset can match; skip fetching the column entirely
103
+ ```
104
+
105
+ The index is compressed with zstd by default
106
+ (`StoreConfig.pruning_compression`) — on that same 10 000-dataset collection
107
+ it is 0.29 MB rather than 17.4 MB. Blocks are compressed individually, so
108
+ single-column reads stay ranged whatever codec you pick, and the codec is
109
+ recorded in the index itself so readers adapt without being told.
110
+
111
+ ### Types
112
+
113
+ Statistics keep the type they were computed with — nothing is cast. `min` and
114
+ `max` come back as `int64`, `uint64`, `float64`, or a list of `bytes | None`,
115
+ chosen from what the column actually holds. A column that is `int32` in some
116
+ datasets and `float64` in others promotes to `float64` at the numpy boundary.
117
+
118
+ For a column's collection-wide *declared* type, use
119
+ [`merged_schema()`](datasets-and-arrays.md) — the index itself stores no dtype.
120
+
121
+ ### Attribute columns
122
+
123
+ Attribute columns currently record **presence only**: `present` tells you which
124
+ datasets carry the key, but `stats_valid` is `False` and `min`/`max` are unset.
125
+ Filtering on attribute *values* through the index isn't wired up yet — read
126
+ them per dataset with `attributes()` for now.
127
+
128
+ [`examples/06_stats_scan.py`](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/06_stats_scan.py)
129
+ runs a per-dataset scan across 32 sensor datasets.
130
+
131
+ ## How `null_count` works with `fill_value`
132
+
133
+ A "null" in atlas is **a cell whose stored value equals the array's
134
+ `fill_value`**. Both *unwritten* cells (which read back as the fill value)
135
+ and *written* cells whose value happens to match contribute to the count.
136
+
137
+ For float arrays the natural choice is `fill_value=float("nan")` — NaN
138
+ doesn't compare equal to itself by IEEE rules, but atlas special-cases
139
+ NaN, so a written NaN counts as a null.
140
+
141
+ ```python
142
+ ds.define_array("temp", dtype="float32", ..., fill_value=float("nan"))
143
+ ds.write_array("temp", start=[0], data=np.array([1.0, np.nan, 3.0], dtype=np.float32))
144
+ atlas.flush()
145
+ ds.array_stats("temp")
146
+ # {"row_count": 3, "null_count": 1, "min": 1.0, "max": 3.0}
147
+ ```
148
+
149
+ For integer arrays, pick a sentinel value (`-1`, `np.iinfo(dtype).min`,
150
+ etc.) — any *written* cell equal to it will also be counted as null. Pick
151
+ a value that can't appear in real data.
152
+
153
+ When ingesting via [`add_xarray_dataset`](xarray.md#fill-values-and-missing-data)
154
+ you don't set these by hand: float arrays default to a `NaN` fill, datetimes
155
+ to `NaT`, and strings to `""`, so cells masked by `mask_and_scale=True` are
156
+ counted as null automatically. Pass `fill_value=` to override.
157
+
158
+ ## What stats don't include
159
+
160
+ - **No mean, sum, stdev, or quantiles.** If you need those, read the array
161
+ and compute them with numpy / dask. Atlas's stats are designed to be
162
+ cheap-on-write and cheap-on-read, not a full analytics engine.
163
+ - **No per-dimension reductions.** `min` / `max` are scalars over the
164
+ whole array, not per-row / per-column.
165
+ - **No per-chunk zone maps.** Statistics are per array per dataset; there is
166
+ no finer granularity to prune within a large array.
167
+ - **`List` / `FixedSizeList` arrays get no statistics at all** — not even
168
+ counts.
169
+
170
+ Strings and timestamps *are* covered. String `min`/`max` come back as `bytes`
171
+ and order lexicographically; timestamps come back as integer nanoseconds since
172
+ the epoch:
173
+
174
+ ```python
175
+ ds.array_stats("station")["min"] # b"AAA01"
176
+ np.array(ds.array_stats("time")["min"]).astype("datetime64[ns]")
177
+ ```
@@ -40,6 +40,11 @@ with atlas.Atlas.create("/tmp/my_store", codec="zstd") as store:
40
40
  - **Bulk cross-dataset APIs** — `open_as_many_xarray_dataset` and
41
41
  `read_array_across_stacked` collapse N per-dataset reads into one PyO3
42
42
  call, sharing a single file handle.
43
+ - **Cross-dataset pruning** — a columnar min/max/count index
44
+ (`store.pruning_index(...)` / `column_summaries()`) prunes "which of my
45
+ 10 000 datasets could match this predicate?" to a handful of candidates
46
+ with a single vectorised scan, without opening any array file. See
47
+ [Stats and scans](guides/stats.md).
43
48
  - **Sync API, GIL-released** — a multi-threaded tokio runtime backs every
44
49
  blocking call; the GIL is released so other Python threads can run.
45
50
  - **Local fs or cloud storage** — pass a path string for local, or an
@@ -8,19 +8,19 @@ pub fn py_to_attr(value: &Bound<'_, PyAny>, dtype_hint: Option<&str>) -> PyResul
8
8
  return py_to_attr_typed(value, hint);
9
9
  }
10
10
 
11
- if let Ok(b) = value.downcast::<PyBool>() {
11
+ if let Ok(b) = value.cast::<PyBool>() {
12
12
  return Ok(Attr::Bool(b.is_true()));
13
13
  }
14
- if value.downcast::<PyString>().is_ok() {
14
+ if value.cast::<PyString>().is_ok() {
15
15
  return Ok(Attr::String(value.extract::<String>()?));
16
16
  }
17
- if value.downcast::<PyBytes>().is_ok() {
17
+ if value.cast::<PyBytes>().is_ok() {
18
18
  return Ok(Attr::Binary(value.extract::<Vec<u8>>()?));
19
19
  }
20
- if value.downcast::<PyInt>().is_ok() {
20
+ if value.cast::<PyInt>().is_ok() {
21
21
  return Ok(Attr::Int64(value.extract::<i64>()?));
22
22
  }
23
- if value.downcast::<PyFloat>().is_ok() {
23
+ if value.cast::<PyFloat>().is_ok() {
24
24
  return Ok(Attr::Float64(value.extract::<f64>()?));
25
25
  }
26
26
  Err(PyValueError::new_err(format!(