atlas-python 0.10.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {atlas_python-0.10.0 → atlas_python-0.13.0}/.gitignore +2 -0
  2. {atlas_python-0.10.0 → atlas_python-0.13.0}/Cargo.lock +4 -4
  3. {atlas_python-0.10.0 → atlas_python-0.13.0}/Cargo.toml +2 -2
  4. {atlas_python-0.10.0 → atlas_python-0.13.0}/PKG-INFO +46 -6
  5. {atlas_python-0.10.0 → atlas_python-0.13.0}/README.md +82 -15
  6. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/Cargo.toml +1 -1
  7. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/README.md +45 -5
  8. atlas_python-0.13.0/atlas-python/docs/guides/attributes.md +115 -0
  9. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/datasets-and-arrays.md +72 -8
  10. atlas_python-0.13.0/atlas-python/docs/guides/stats.md +177 -0
  11. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/index.md +5 -0
  12. atlas_python-0.13.0/atlas-python/src/attr.rs +95 -0
  13. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/src/dataset.rs +73 -22
  14. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/src/error.rs +10 -3
  15. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/src/store.rs +246 -58
  16. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/tests/test_smoke.py +43 -0
  17. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/tests/test_xarray.py +15 -12
  18. {atlas_python-0.10.0 → atlas_python-0.13.0}/examples/lifecycle.rs +4 -4
  19. {atlas_python-0.10.0 → atlas_python-0.13.0}/examples/sensor_fleet.rs +2 -2
  20. {atlas_python-0.10.0 → atlas_python-0.13.0}/examples/weather_store.rs +17 -17
  21. {atlas_python-0.10.0 → atlas_python-0.13.0}/pyproject.toml +1 -1
  22. {atlas_python-0.10.0 → atlas_python-0.13.0}/python/atlas/__init__.py +2 -1
  23. {atlas_python-0.10.0 → atlas_python-0.13.0}/python/atlas/__init__.pyi +123 -7
  24. atlas_python-0.13.0/python/atlas/store.py +106 -0
  25. {atlas_python-0.10.0 → atlas_python-0.13.0}/python/atlas/xarray.py +17 -15
  26. {atlas_python-0.10.0 → atlas_python-0.13.0}/src/array.rs +29 -0
  27. {atlas_python-0.10.0 → atlas_python-0.13.0}/src/config.rs +71 -1
  28. atlas_python-0.13.0/src/dataset/cache.rs +54 -0
  29. atlas_python-0.13.0/src/dataset/mod.rs +11 -0
  30. atlas_python-0.13.0/src/dataset/pending_attrs.rs +244 -0
  31. atlas_python-0.10.0/src/dataset.rs → atlas_python-0.13.0/src/dataset/view.rs +374 -144
  32. {atlas_python-0.10.0 → atlas_python-0.13.0}/src/error.rs +44 -5
  33. {atlas_python-0.10.0 → atlas_python-0.13.0}/src/lib.rs +27 -10
  34. atlas_python-0.13.0/src/meta/mod.rs +21 -0
  35. atlas_python-0.13.0/src/meta/persist.rs +449 -0
  36. atlas_python-0.13.0/src/meta/schema.rs +230 -0
  37. atlas_python-0.13.0/src/meta/store_meta.rs +668 -0
  38. atlas_python-0.13.0/src/meta/type_index.rs +175 -0
  39. atlas_python-0.13.0/src/pruning/bitmap.rs +121 -0
  40. atlas_python-0.13.0/src/pruning/column.rs +414 -0
  41. atlas_python-0.13.0/src/pruning/format.rs +270 -0
  42. atlas_python-0.13.0/src/pruning/io.rs +266 -0
  43. atlas_python-0.13.0/src/pruning/mod.rs +463 -0
  44. atlas_python-0.13.0/src/pruning/value.rs +129 -0
  45. atlas_python-0.13.0/src/schema/array.rs +43 -0
  46. atlas_python-0.13.0/src/schema/attr.rs +252 -0
  47. atlas_python-0.13.0/src/schema/dtype.rs +277 -0
  48. atlas_python-0.13.0/src/schema/mod.rs +17 -0
  49. atlas_python-0.13.0/src/store/bulk_read.rs +228 -0
  50. atlas_python-0.13.0/src/store/durability.rs +282 -0
  51. atlas_python-0.10.0/src/store.rs → atlas_python-0.13.0/src/store/mod.rs +184 -303
  52. atlas_python-0.13.0/tests/integration.rs +1518 -0
  53. atlas_python-0.10.0/atlas-python/docs/guides/attributes.md +0 -103
  54. atlas_python-0.10.0/atlas-python/docs/guides/stats.md +0 -92
  55. atlas_python-0.10.0/atlas-python/src/attr.rs +0 -60
  56. atlas_python-0.10.0/src/meta.rs +0 -398
  57. atlas_python-0.10.0/src/schema.rs +0 -164
  58. atlas_python-0.10.0/tests/integration.rs +0 -414
  59. {atlas_python-0.10.0 → atlas_python-0.13.0}/.github/workflows/atlas-python-docs.yaml +0 -0
  60. {atlas_python-0.10.0 → atlas_python-0.13.0}/.github/workflows/atlas-python-release.yaml +0 -0
  61. {atlas_python-0.10.0 → atlas_python-0.13.0}/.github/workflows/atlas-rust-release.yaml +0 -0
  62. {atlas_python-0.10.0 → atlas_python-0.13.0}/.github/workflows/ci.yaml +0 -0
  63. {atlas_python-0.10.0 → atlas_python-0.13.0}/CONTRIBUTING.md +0 -0
  64. {atlas_python-0.10.0 → atlas_python-0.13.0}/LICENSE +0 -0
  65. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/.python-version +0 -0
  66. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/LICENSE +0 -0
  67. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/benchmarks/README.md +0 -0
  68. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/benchmarks/_common.py +0 -0
  69. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/benchmarks/bench_collection.py +0 -0
  70. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/benchmarks/generate_bench_charts.py +0 -0
  71. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/assets/bench_gridded.svg +0 -0
  72. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/assets/bench_profile.svg +0 -0
  73. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/benchmarks.md +0 -0
  74. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/examples.md +0 -0
  75. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/bulk-reads.md +0 -0
  76. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/cloud-storage.md +0 -0
  77. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/codecs-and-meta.md +0 -0
  78. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/dask.md +0 -0
  79. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/dtypes.md +0 -0
  80. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/durability.md +0 -0
  81. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/shared-arrays.md +0 -0
  82. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/guides/xarray.md +0 -0
  83. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/installation.md +0 -0
  84. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/quickstart.md +0 -0
  85. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/reference/atlas.md +0 -0
  86. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/reference/dataset-view.md +0 -0
  87. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/reference/xarray-accessor.md +0 -0
  88. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
  89. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/01_basics.py +0 -0
  90. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/02_xarray.py +0 -0
  91. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/03_dask_streaming.py +0 -0
  92. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/04_meta_formats.py +0 -0
  93. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/05_codecs.py +0 -0
  94. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/06_stats_scan.py +0 -0
  95. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/07_shared_arrays.py +0 -0
  96. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/08_object_store.py +0 -0
  97. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/examples/09_missing_data.py +0 -0
  98. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/mkdocs.yml +0 -0
  99. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/src/dtype.rs +0 -0
  100. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/src/lib.rs +0 -0
  101. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/src/logging.rs +0 -0
  102. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/src/runtime.rs +0 -0
  103. {atlas_python-0.10.0 → atlas_python-0.13.0}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  104. {atlas_python-0.10.0 → atlas_python-0.13.0}/python/atlas/py.typed +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.10.0"
31
+ version = "0.12.0"
32
32
  source = "registry+https://github.com/rust-lang/crates.io-index"
33
- checksum = "a637a3b5f23ba45eb53f09e643b363163114f0e94d0f6f05f330134cb86d381b"
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.10.0"
74
+ version = "0.13.0"
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.10.0"
89
+ version = "0.13.0"
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.10.0"
8
+ version = "0.13.0"
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.10.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.10.0
3
+ Version: 0.13.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Programming Language :: Python :: 3
@@ -179,8 +179,8 @@ picklable, so call `.compute()` before handing off to distributed/multiprocessin
179
179
  | Item | How it's stored |
180
180
  | --- | --- |
181
181
  | Each coord / data variable | A separate array, with `dims` mapped 1:1. |
182
- | Dataset attrs | Dataset attributes, plain keys. |
183
- | Per-variable attrs | Flattened as `{var}.{attr}` at the dataset attr level. |
182
+ | Dataset attrs | Dataset-level (global) attributes, plain keys. |
183
+ | Per-variable attrs | Real per-variable attributes on each variable's array. |
184
184
  | Per-variable `_FillValue` | Consumed by `define_array` as a typed fill value (source `Dataset.attrs` is not mutated). See [Missing data](#missing-data). |
185
185
  | Coord vs data_var distinction | JSON list in the internal `_pyatlas_coords` attr. |
186
186
  | Non-scalar attr values (list, ndarray) | JSON-encoded string with a `json:` prefix marker. |
@@ -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`
@@ -261,8 +299,10 @@ handle instead of a local path. The path-based local-filesystem API works withou
261
299
  | `delete_array(name)` | Tombstone the array within this dataset. |
262
300
  | `array_meta(name) -> dict \| None` | `{"dtype", "shape", "chunk_shape", "dimension_names"}`. |
263
301
  | `array_stats(name) -> dict \| None` | `{"row_count", "null_count", "min", "max"}` — populated after `flush()`. |
264
- | `set_attribute(key, value, dtype=None)` | Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). On disk: bool, int64, float64, string, timestamp_nanoseconds. |
265
- | `get_attribute(key)` / `attributes()` | Single attribute or dict of all. |
302
+ | `set_attribute(key, value, dtype=None)` | Set a dataset-level (global) attribute. Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). Values are stored in the `_global` `.af` file. |
303
+ | `get_attribute(key)` / `attributes()` | Single global attribute or dict of all. |
304
+ | `set_array_attribute(array, key, value, dtype=None)` | Set a per-variable attribute on `array` (e.g. `units`); `KeyError` if the array isn't defined. |
305
+ | `get_array_attribute(array, key)` / `array_attributes(array)` | Single per-variable attribute or dict of all, for `array`. |
266
306
 
267
307
  `DatasetView` does **not** expose its own `flush` / `compact` — both go through the parent `Atlas`.
268
308
 
@@ -21,26 +21,31 @@ A directory-based store for thousands of named datasets, each holding N-dimensio
21
21
 
22
22
  `atlas` is designed for workloads where you have a large collection of similarly-shaped datasets — such as one dataset per time step, sensor station, or simulation run — and you want to query a single variable (e.g. `temperature`) across all of them efficiently.
23
23
 
24
- Each dataset is a named group of N-dimensional arrays with typed per-dataset attributes. Datasets that share an array name (e.g. every `jan_2024`, `feb_2024`, … all have `temperature`) are stored together in the same physical file, keyed by dataset name inside the file.
24
+ Each dataset is a named group of N-dimensional arrays with typed attributes — both dataset-level (global) attributes and per-variable attributes. Datasets that share an array name (e.g. every `jan_2024`, `feb_2024`, … all have `temperature`) are stored together in the same physical file, keyed by dataset name inside the file.
25
25
 
26
26
  ```text
27
27
  my_store/
28
- ├── atlas.json ← dataset registry and per-dataset attributes (JSON)
28
+ ├── atlas.json ← interned per-dataset schema + attribute-key namespace (JSON)
29
+ ├── pruning.idx ← columnar min/max/count index across all datasets (see below)
30
+ ├── _global/
31
+ │ └── data.af ← dataset-level (global) attribute values, one entry per dataset
29
32
  ├── temperature/
30
- │ └── data.af ← one ArrayFile holding temperature for every dataset
33
+ │ └── data.af ← one ArrayFile: temperature + its per-variable attributes, per dataset
31
34
  ├── pressure/
32
35
  │ └── data.af
33
36
  └── time/
34
37
  └── data.af
35
38
  ```
36
39
 
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.
41
+
37
42
  ---
38
43
 
39
44
  ## Durability model
40
45
 
41
- `atlas.json` is read **once** when the store is opened or created. Every subsequent mutation — `create_dataset`, `define_array`, `set_attribute`, `delete_array`, `delete_dataset` — only touches the in-memory `StoreMeta`; writes to `ArrayFile`s buffer inside the per-array in-memory layer. **Nothing reaches disk until `Atlas::flush()` (or `Atlas::close()`).** Dropping an `Atlas` without flushing abandons every pending in-memory write.
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.
42
47
 
43
- A single `Atlas::flush()` walks every cached `ArrayFile` (writing deltas + stats) and then serialises the in-memory `StoreMeta` 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).
44
49
 
45
50
  `DatasetView` is a borrowed handle into the atlas's shared meta — it has no `flush()` of its own.
46
51
 
@@ -50,14 +55,25 @@ A single `Atlas::flush()` walks every cached `ArrayFile` (writing deltas + stats
50
55
 
51
56
  ### `atlas.json`
52
57
 
53
- The registry is a plain JSON file written on `Atlas::flush()` / `Atlas::close()`. It stores:
58
+ The schema is a plain JSON file written on `Atlas::flush()` / `Atlas::close()`. It stores:
59
+
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.
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.
62
+ - **Dataset registry** — a map of dataset name → schema-pool index.
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()`.
54
64
 
55
- - **Store version** for future format upgrades.
56
- - **Dataset names** — the complete list of datasets in the store.
57
- - **Per-dataset attributes** — typed key-value pairs serialized as plain JSON values: bool, 64-bit integer, 64-bit float, UTF-8 string, or an RFC 3339 nanosecond-precision timestamp string (e.g. `"2023-11-15T07:33:20.123456789Z"`). Atlas's `Attr` enum has five variants (`Bool`/`Int64`/`Float64`/`String`/`TimestampNanoseconds`); there are no narrow integer/float types on disk.
58
- - **Array schemas** — per array: dtype, shape, chunk shape, named dimensions, and the codec used when the array was first written.
65
+ When the same array name (or attribute key) appears in more than one dataset with different types, the merged type is **widened**. Widening is allowed only within numeric types (e.g. `int16` ∪ `int32` → `int32`; `int32` ∪ `float32` → `float64`) or between `string` and `timestamp` (→ `string`).
59
66
 
60
- Because `atlas.json` is human-readable and self-describing, you can inspect or audit the store contents with any JSON tool without needing the library.
67
+ Any other combination — e.g. an `int32` array in one dataset and a `string` array under the same name in another — can't merge. The dataset is **still stored** under its own type (each dataset keeps the type it declared, and its data reads back normally); the merged schema simply keeps the **first-seen** type. Whether that's reported as a warning or an error is set by `StoreConfig::on_type_mismatch`:
68
+
69
+ | `TypeMismatchPolicy` | Behaviour |
70
+ | --- | --- |
71
+ | `Warn` (default) | Logs a `tracing` warning and carries on — an ingest of many heterogeneous files won't abort because one disagrees. |
72
+ | `Error` | `define_array` / `set_attribute` / `set_array_attribute` return `Error::TypeMismatch`. |
73
+
74
+ The policy is a **per-session** choice, not an on-disk property: pass it to `Atlas::create`, or to `Atlas::open_with_config` / `Atlas::open_path_with_config` when opening an existing collection (plain `open` / `open_path` use the default). Note that these open variants honour only `on_type_mismatch` — the codec and metadata format are always detected from disk.
75
+
76
+ Attribute **values** are **not** in `atlas.json` — only their key names are (as part of the schema). Values live in the `.af` files and are read via the `array-format` attribute API. Because `atlas.json` is human-readable and self-describing, you can still inspect or audit the collection's schema with any JSON tool without needing the library.
61
77
 
62
78
  ### `<array_name>/data.af`
63
79
 
@@ -67,9 +83,56 @@ Each array variable gets its own subdirectory with a single `data.af` binary fil
67
83
  - **Chunked layout** — arrays are split into chunks of a user-specified shape, so partial reads and writes touch only the relevant blocks.
68
84
  - **Configurable compression** — each block is compressed with the codec set when the store was created (default: Zstd; also LZ4 and uncompressed). The codec is persisted in `atlas.json` and restored automatically on `open` — no need to pass it again. Block target size is 8 MiB.
69
85
  - **Per-array fill value** — `define_array` accepts an optional `FillValue` (one of `Bool`/`Int`/`UInt`/`Float`/`String`). Unwritten cells read back as the fill value, and any *written* cell equal to it is counted as a null in the persisted stats (see below). Fill values are stored in the per-array footer; `atlas.json` is not extended.
86
+ - **Attribute values** — per-variable attributes (e.g. `units`) are stored in the footer of the variable's own `data.af`, keyed by dataset. Dataset-level (global) attributes live the same way in the reserved `_global/data.af`. Atlas's `Attr` type mirrors `array-format`'s `AttributeValue` (bool, sized ints/uints, `f32`/`f64`, string, binary, and typed lists) plus a nanosecond timestamp variant stored as an RFC 3339 string.
70
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.
71
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.
72
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
+
73
136
  ---
74
137
 
75
138
  ## Quick start
@@ -133,8 +196,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
133
196
  | **Array** | An N-dimensional typed array with named dimensions and an optional chunk shape. |
134
197
  | **Attribute** | A typed scalar attached to a dataset (metadata, not array data). |
135
198
  | **Array file** | One `data.af` file per variable name, shared across all datasets that define that variable. |
136
- | **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:`). |
137
- | **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. |
138
202
  | **StoreConfig** | Configuration passed to `Atlas::create`. Currently holds the compression `Codec`. |
139
203
  | **Codec** | Compression codec for new array blocks: `Codec::Zstd` (default), `Codec::Lz4`, or `Codec::Uncompressed`. Persisted in `atlas.json`; `open` reads it automatically. |
140
204
 
@@ -199,6 +263,7 @@ Reading `temperature` for 1 000 datasets means opening exactly **one file**. Thi
199
263
  | Cross-dataset column scan | Slow (N file opens) | Slow (N directory opens) | Fast (1 file open) |
200
264
  | Partial reads | Yes | Yes | Yes |
201
265
  | Statistics (min/max/nulls) | No | No | Yes (persisted on flush) |
266
+ | Cross-dataset stat pruning | No | No | Yes (`pruning.idx`, vectorised) |
202
267
  | Self-describing metadata | Yes | Yes | Yes (`atlas.json`) |
203
268
  | Language support | C/Python/Julia/… | Python/Java/… | Rust |
204
269
  | Mutable after write | Limited | Yes | Yes (chunked overwrites + compact) |
@@ -246,10 +311,12 @@ Two caches sit in front of the object store:
246
311
 
247
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`.
248
313
 
249
- ### Persisted statistics
314
+ ### Persisted statistics and pruning
250
315
 
251
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.
252
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
+
253
320
  ### Compression
254
321
 
255
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.
@@ -264,7 +331,7 @@ Choose LZ4 when decompression throughput matters more than storage size (e.g. la
264
331
 
265
332
  ### Write path
266
333
 
267
- 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.
268
335
 
269
336
  ### Compaction
270
337
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "atlas-python"
3
- version = "0.10.0"
3
+ version = "0.13.0"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -147,8 +147,8 @@ picklable, so call `.compute()` before handing off to distributed/multiprocessin
147
147
  | Item | How it's stored |
148
148
  | --- | --- |
149
149
  | Each coord / data variable | A separate array, with `dims` mapped 1:1. |
150
- | Dataset attrs | Dataset attributes, plain keys. |
151
- | Per-variable attrs | Flattened as `{var}.{attr}` at the dataset attr level. |
150
+ | Dataset attrs | Dataset-level (global) attributes, plain keys. |
151
+ | Per-variable attrs | Real per-variable attributes on each variable's array. |
152
152
  | Per-variable `_FillValue` | Consumed by `define_array` as a typed fill value (source `Dataset.attrs` is not mutated). See [Missing data](#missing-data). |
153
153
  | Coord vs data_var distinction | JSON list in the internal `_pyatlas_coords` attr. |
154
154
  | Non-scalar attr values (list, ndarray) | JSON-encoded string with a `json:` prefix marker. |
@@ -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`
@@ -229,8 +267,10 @@ handle instead of a local path. The path-based local-filesystem API works withou
229
267
  | `delete_array(name)` | Tombstone the array within this dataset. |
230
268
  | `array_meta(name) -> dict \| None` | `{"dtype", "shape", "chunk_shape", "dimension_names"}`. |
231
269
  | `array_stats(name) -> dict \| None` | `{"row_count", "null_count", "min", "max"}` — populated after `flush()`. |
232
- | `set_attribute(key, value, dtype=None)` | Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). On disk: bool, int64, float64, string, timestamp_nanoseconds. |
233
- | `get_attribute(key)` / `attributes()` | Single attribute or dict of all. |
270
+ | `set_attribute(key, value, dtype=None)` | Set a dataset-level (global) attribute. Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). Values are stored in the `_global` `.af` file. |
271
+ | `get_attribute(key)` / `attributes()` | Single global attribute or dict of all. |
272
+ | `set_array_attribute(array, key, value, dtype=None)` | Set a per-variable attribute on `array` (e.g. `units`); `KeyError` if the array isn't defined. |
273
+ | `get_array_attribute(array, key)` / `array_attributes(array)` | Single per-variable attribute or dict of all, for `array`. |
234
274
 
235
275
  `DatasetView` does **not** expose its own `flush` / `compact` — both go through the parent `Atlas`.
236
276
 
@@ -0,0 +1,115 @@
1
+ # Attributes
2
+
3
+ Each [`DatasetView`](../reference/dataset-view.md) carries typed attributes at
4
+ two levels: **dataset-level** (global) attributes and **per-variable**
5
+ attributes on individual arrays. Attribute *values* are stored in the `.af`
6
+ files — global values in a reserved `_global` file, per-variable values on the
7
+ variable's own file. Only the attribute *key names* are recorded in
8
+ `atlas.json` (as part of the schema), so listing which attributes exist is
9
+ cheap and doesn't load any array bytes.
10
+
11
+ ## Dataset-level (global) attributes
12
+
13
+ ```python
14
+ ds = atlas.create_dataset("jan_2024")
15
+
16
+ ds.set_attribute("month", 1) # inferred int
17
+ ds.set_attribute("station", "KNMI") # inferred str
18
+ ds.set_attribute("calibrated", True) # inferred bool
19
+
20
+ ds.get_attribute("month") # -> 1
21
+ ds.get_attribute("missing") # -> None
22
+ ds.attributes() # -> {"month": 1, "station": "KNMI", "calibrated": True}
23
+ ```
24
+
25
+ ## Per-variable attributes
26
+
27
+ Attributes can also attach to a specific array (e.g. `units` on `temperature`):
28
+
29
+ ```python
30
+ ds.define_array("temperature", dtype="float32", dims=["lat", "lon"], shape=[4, 8])
31
+ ds.set_array_attribute("temperature", "units", "degC")
32
+ ds.set_array_attribute("temperature", "valid_range", [-40.0, 60.0], dtype="f64")
33
+
34
+ ds.get_array_attribute("temperature", "units") # -> "degC"
35
+ ds.array_attributes("temperature") # -> {"units": "degC", "valid_range": [...]}
36
+ ```
37
+
38
+ `set_array_attribute` raises `KeyError` if the array isn't defined in the
39
+ dataset.
40
+
41
+ ## Durability
42
+
43
+ Attribute writes are buffered in memory and only reach disk on
44
+ [`atlas.flush()`](durability.md) (or leaving a `with atlas:` block), together
45
+ with the array data. The reserved `_global/data.af` file is created lazily —
46
+ only once a dataset actually sets a global attribute.
47
+
48
+ ## The on-disk type system
49
+
50
+ Atlas's `Attr` type mirrors the underlying `array-format` `AttributeValue`:
51
+
52
+ | On-disk type | Python type returned on read |
53
+ |---|---|
54
+ | `bool` | `bool` |
55
+ | `int8` / `int16` / `int32` / `int64` | `int` |
56
+ | `uint8` / `uint16` / `uint32` / `uint64` | `int` |
57
+ | `float32` / `float64` | `float` |
58
+ | `string` | `str` |
59
+ | `binary` | `bytes` |
60
+ | `timestamp_nanoseconds` | `int` (nanoseconds; stored as an RFC 3339 string) |
61
+ | any of the above as a list | `list` |
62
+
63
+ Type is inferred from the Python value by default — `int` → `int64`,
64
+ `float` → `float64`, `str` → `string`, `bytes` → `binary`, `bool` → `bool`.
65
+
66
+ ## Overriding inferred types
67
+
68
+ Pass `dtype=` to narrow or force a specific type (works on both
69
+ `set_attribute` and `set_array_attribute`):
70
+
71
+ ```python
72
+ ds.set_attribute("sensor_id", 7, dtype="int8") # stored as int8, range-checked
73
+ ds.set_attribute("ratio", 0.5, dtype="float32") # stored as float32
74
+ ds.set_attribute("observed_at",
75
+ np.datetime64("2024-01-15T10:00", "ns").astype("int64").item(),
76
+ dtype="timestamp_nanoseconds")
77
+ ```
78
+
79
+ Unlike earlier versions, width hints now preserve the storage type: `dtype="int8"`
80
+ stores an 8-bit integer, not a widened `int64`. A `timestamp_nanoseconds`
81
+ attribute is stored as an RFC 3339 string and restored to a timestamp on read.
82
+
83
+ ## Per-variable xarray attributes
84
+
85
+ When you write an `xr.Dataset` via `atlas.add_xarray_dataset(ds, name)`, each
86
+ variable's `attrs` are stored as **real per-variable attributes** on that
87
+ variable's array, and the dataset's own `attrs` become dataset-level
88
+ attributes:
89
+
90
+ ```python
91
+ ds = xr.Dataset(
92
+ data_vars={"temperature": xr.DataArray(arr, dims=["lat", "lon"],
93
+ attrs={"units": "C"})},
94
+ attrs={"station": "KNMI"},
95
+ )
96
+ atlas.add_xarray_dataset(ds, "jan_2024")
97
+
98
+ view = atlas.open_dataset("jan_2024")
99
+ view.attributes() # {"station": "KNMI"}
100
+ view.array_attributes("temperature") # {"units": "C"}
101
+ ```
102
+
103
+ On read, `atlas.open_as_xarray_dataset("jan_2024")` puts each variable's attrs
104
+ back on the right `DataArray` and the global attrs back on the Dataset.
105
+
106
+ See [xarray integration](xarray.md) for the full storage convention.
107
+
108
+ ## JSON-encoded "complex" attributes
109
+
110
+ xarray attribute values are sometimes nested dicts or other structures that
111
+ don't map to atlas's on-disk types. The xarray bridge JSON-encodes those and
112
+ prefixes the string with `json:`, decoding transparently on read. You generally
113
+ don't need to think about this, but it's why some values come back as strings
114
+ starting with `json:` if you read them through the raw attribute API. Simple
115
+ lists of scalars are stored natively as typed-list attributes.
@@ -4,18 +4,24 @@
4
4
 
5
5
  An [`Atlas`](../reference/atlas.md) is a directory-backed handle. It owns:
6
6
 
7
- 1. An in-memory **`StoreMeta`** — every dataset, every array schema, every
8
- attribute. Loaded once at `open`/`create`, mutated by every write,
9
- persisted on `flush()`.
7
+ 1. An in-memory **`StoreMeta`** — the collection's schema: every dataset and
8
+ every array schema, plus the attribute-key namespace (which global and
9
+ per-variable attribute keys exist). Distinct dataset schemas are interned,
10
+ so thousands of identical schemas cost one copy. Loaded once at
11
+ `open`/`create`, mutated by every schema write, persisted to `atlas.json`
12
+ on `flush()`.
10
13
  2. A set of **array file caches** — one buffer per array name across the
11
- whole store. Pending writes accumulate here until `flush()`.
14
+ whole store. Pending array writes accumulate here until `flush()`.
15
+ 3. A **pending-attribute buffer** — attribute *values* set via `set_attribute`
16
+ / `set_array_attribute`, drained into the `.af` files on `flush()`.
12
17
 
13
18
  A [`DatasetView`](../reference/dataset-view.md) is a typed handle into a
14
19
  single logical dataset. It exposes the per-dataset array schemas, the
15
- per-array statistics, and the per-dataset attribute dict. Mutations through
16
- the view (`define_array`, `write_array`, `set_attribute`, …) update the
17
- parent atlas's in-memory state; nothing reaches disk until you flush the
18
- *atlas*.
20
+ per-array statistics, dataset-level (global) attributes, and per-variable
21
+ attributes. Mutations through the view (`define_array`, `write_array`,
22
+ `set_attribute`, `set_array_attribute`, …) update the parent atlas's in-memory
23
+ state; attribute *values* live in the `.af` files, and nothing reaches disk
24
+ until you flush the *atlas*.
19
25
 
20
26
  ```text
21
27
  Atlas ── StoreMeta (in-memory) ─┬─ DatasetView "jan_2024"
@@ -50,6 +56,27 @@ jan = atlas.open_dataset("jan_2024")
50
56
  atlas.delete_dataset("feb_2024")
51
57
  ```
52
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
+
53
80
  ## Declaring an array
54
81
 
55
82
  `define_array` records the schema (dtype, dims, shape, chunking, fill
@@ -142,3 +169,40 @@ jan.delete_array("temperature") # tombstone within this dataset
142
169
 
143
170
  The array's bytes inside the shared physical file are tombstoned; reclaim
144
171
  the space with [`atlas.compact()`](durability.md).
172
+
173
+ ## Merged schema and type widening
174
+
175
+ `atlas.json` also carries a collection-wide **merged schema** — every unique
176
+ array (with its dtype, dimensions, and per-variable attribute types) and every
177
+ global attribute type, folded across all datasets. Read it with
178
+ `atlas.merged_schema()`:
179
+
180
+ ```python
181
+ merged = store.merged_schema()
182
+ merged["arrays"]["temperature"]["dtype"] # widened dtype across datasets
183
+ merged["arrays"]["temperature"]["attributes"] # {attr_key: dtype}
184
+ merged["global_attributes"] # {attr_key: dtype}
185
+ ```
186
+
187
+ When the same array name or attribute key appears in multiple datasets with
188
+ different types, the merged type is **widened** — but only within numeric types
189
+ (`int16` ∪ `int32` → `int32`, `int32` ∪ `float32` → `float64`) or between
190
+ `string` and `timestamp` (→ `string`).
191
+
192
+ Anything else can't merge (e.g. an `int32` array in one dataset and a `string`
193
+ array under the same name in another). The dataset is **still stored** under its
194
+ own type and reads back normally; the merged schema just keeps the
195
+ **first-seen** type. `on_type_mismatch` decides how that's reported:
196
+
197
+ ```python
198
+ # "warn" (default): stored, logs a warning, merged keeps the first type
199
+ store = atlas.Atlas.create("/tmp/store")
200
+ store = atlas.Atlas.open("/tmp/store")
201
+
202
+ # "error": the mismatching define_array / set_attribute raises ValueError
203
+ store = atlas.Atlas.create("/tmp/store", on_type_mismatch="error")
204
+ store = atlas.Atlas.open("/tmp/store", on_type_mismatch="error")
205
+ ```
206
+
207
+ It's a per-session choice — it isn't stored in `atlas.json`, so pass it each
208
+ time you open the collection.