atlas-python 0.9.1__tar.gz → 0.12.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 (83) hide show
  1. atlas_python-0.12.0/.github/workflows/atlas-python-docs.yaml +88 -0
  2. {atlas_python-0.9.1 → atlas_python-0.12.0}/.gitignore +5 -1
  3. {atlas_python-0.9.1 → atlas_python-0.12.0}/CONTRIBUTING.md +1 -1
  4. {atlas_python-0.9.1 → atlas_python-0.12.0}/Cargo.lock +4 -4
  5. {atlas_python-0.9.1 → atlas_python-0.12.0}/Cargo.toml +2 -2
  6. {atlas_python-0.9.1 → atlas_python-0.12.0}/PKG-INFO +44 -20
  7. {atlas_python-0.9.1 → atlas_python-0.12.0}/README.md +38 -15
  8. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/Cargo.toml +1 -1
  9. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/README.md +43 -19
  10. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/benchmarks/README.md +4 -4
  11. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/benchmarks/bench_collection.py +9 -9
  12. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/benchmarks.md +4 -4
  13. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/examples.md +2 -1
  14. atlas_python-0.12.0/atlas-python/docs/guides/attributes.md +115 -0
  15. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/bulk-reads.md +8 -8
  16. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/cloud-storage.md +7 -7
  17. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/dask.md +6 -6
  18. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/datasets-and-arrays.md +51 -8
  19. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/dtypes.md +5 -0
  20. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/durability.md +2 -2
  21. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/shared-arrays.md +1 -1
  22. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/stats.md +5 -0
  23. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/xarray.md +53 -8
  24. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/index.md +11 -11
  25. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/installation.md +5 -2
  26. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/quickstart.md +5 -5
  27. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/reference/xarray-accessor.md +2 -2
  28. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/vs-zarr-netcdf.md +2 -2
  29. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/02_xarray.py +6 -6
  30. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/03_dask_streaming.py +3 -3
  31. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/08_object_store.py +4 -4
  32. atlas_python-0.12.0/atlas-python/examples/09_missing_data.py +101 -0
  33. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/mkdocs.yml +4 -0
  34. atlas_python-0.12.0/atlas-python/src/attr.rs +95 -0
  35. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/src/dataset.rs +56 -10
  36. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/src/error.rs +3 -0
  37. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/src/store.rs +82 -15
  38. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/tests/test_smoke.py +1 -1
  39. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/tests/test_xarray.py +220 -48
  40. {atlas_python-0.9.1 → atlas_python-0.12.0}/examples/lifecycle.rs +4 -4
  41. {atlas_python-0.9.1 → atlas_python-0.12.0}/examples/sensor_fleet.rs +2 -2
  42. {atlas_python-0.9.1 → atlas_python-0.12.0}/examples/weather_store.rs +17 -17
  43. {atlas_python-0.9.1 → atlas_python-0.12.0}/pyproject.toml +1 -1
  44. {atlas_python-0.9.1 → atlas_python-0.12.0}/python/atlas/__init__.pyi +62 -12
  45. {atlas_python-0.9.1 → atlas_python-0.12.0}/python/atlas/xarray.py +154 -29
  46. {atlas_python-0.9.1 → atlas_python-0.12.0}/src/array.rs +29 -0
  47. {atlas_python-0.9.1 → atlas_python-0.12.0}/src/config.rs +28 -0
  48. {atlas_python-0.9.1 → atlas_python-0.12.0}/src/dataset.rs +429 -75
  49. {atlas_python-0.9.1 → atlas_python-0.12.0}/src/error.rs +28 -0
  50. {atlas_python-0.9.1 → atlas_python-0.12.0}/src/lib.rs +25 -10
  51. atlas_python-0.12.0/src/meta.rs +745 -0
  52. atlas_python-0.12.0/src/schema.rs +436 -0
  53. {atlas_python-0.9.1 → atlas_python-0.12.0}/src/store.rs +138 -10
  54. {atlas_python-0.9.1 → atlas_python-0.12.0}/tests/integration.rs +352 -20
  55. atlas_python-0.9.1/atlas-python/docs/guides/attributes.md +0 -103
  56. atlas_python-0.9.1/atlas-python/src/attr.rs +0 -60
  57. atlas_python-0.9.1/src/meta.rs +0 -398
  58. atlas_python-0.9.1/src/schema.rs +0 -164
  59. {atlas_python-0.9.1 → atlas_python-0.12.0}/.github/workflows/atlas-python-release.yaml +0 -0
  60. {atlas_python-0.9.1 → atlas_python-0.12.0}/.github/workflows/atlas-rust-release.yaml +0 -0
  61. {atlas_python-0.9.1 → atlas_python-0.12.0}/.github/workflows/ci.yaml +0 -0
  62. {atlas_python-0.9.1 → atlas_python-0.12.0}/LICENSE +0 -0
  63. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/.python-version +0 -0
  64. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/LICENSE +0 -0
  65. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/benchmarks/_common.py +0 -0
  66. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/benchmarks/generate_bench_charts.py +0 -0
  67. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/assets/bench_gridded.svg +0 -0
  68. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/assets/bench_profile.svg +0 -0
  69. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/guides/codecs-and-meta.md +0 -0
  70. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/reference/atlas.md +0 -0
  71. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/docs/reference/dataset-view.md +0 -0
  72. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/01_basics.py +0 -0
  73. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/04_meta_formats.py +0 -0
  74. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/05_codecs.py +0 -0
  75. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/06_stats_scan.py +0 -0
  76. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/examples/07_shared_arrays.py +0 -0
  77. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/src/dtype.rs +0 -0
  78. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/src/lib.rs +0 -0
  79. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/src/logging.rs +0 -0
  80. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/src/runtime.rs +0 -0
  81. {atlas_python-0.9.1 → atlas_python-0.12.0}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  82. {atlas_python-0.9.1 → atlas_python-0.12.0}/python/atlas/__init__.py +0 -0
  83. {atlas_python-0.9.1 → atlas_python-0.12.0}/python/atlas/py.typed +0 -0
@@ -0,0 +1,88 @@
1
+ name: atlas-python-docs
2
+ on:
3
+ workflow_dispatch:
4
+ inputs:
5
+ version:
6
+ description: "Docs version to publish (e.g. 1.2.3). Leave blank to publish as 'dev'."
7
+ required: false
8
+ type: string
9
+ alias:
10
+ description: "Alias to update (e.g. latest)."
11
+ required: false
12
+ default: dev
13
+ type: string
14
+ release:
15
+ types: [published]
16
+ push:
17
+ tags:
18
+ - "v*" # triggers on tags like v1.0.0, v2.3, etc.
19
+ permissions:
20
+ contents: write
21
+ jobs:
22
+ deploy:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - name: Configure Git Credentials
27
+ run: |
28
+ git config user.name github-actions[bot]
29
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
30
+
31
+ - name: Extract version
32
+ id: vars
33
+ env:
34
+ INPUT_VERSION: ${{ github.event.inputs.version }}
35
+ INPUT_ALIAS: ${{ github.event.inputs.alias }}
36
+ run: |
37
+ if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
38
+ # Tag push or release: refs/tags/v1.2.3 -> version=1.2.3, alias=latest
39
+ VERSION="${GITHUB_REF#refs/tags/v}"
40
+ ALIAS=latest
41
+ else
42
+ # Manual run: use provided inputs, falling back to 'dev'
43
+ VERSION="${INPUT_VERSION:-dev}"
44
+ ALIAS="${INPUT_ALIAS:-dev}"
45
+ fi
46
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
47
+ echo "alias=$ALIAS" >> $GITHUB_OUTPUT
48
+
49
+ - name: Show version
50
+ run: |
51
+ echo "Deploying docs version '${{ steps.vars.outputs.version }}' (alias '${{ steps.vars.outputs.alias }}')"
52
+
53
+ - uses: actions/setup-python@v6
54
+ with:
55
+ python-version: "3.12"
56
+ - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
57
+ - uses: actions/cache@v4
58
+ with:
59
+ key: mkdocs-material-${{ env.cache_id }}
60
+ path: ~/.cache
61
+ restore-keys: |
62
+ mkdocs-material-
63
+ - run: pip install mkdocs-material mkdocstrings[python] mike
64
+ # Build & install the package so the compiled `atlas._atlas` extension is
65
+ # importable — mkdocstrings preloads it to resolve the re-exported API.
66
+ # Editable (-e) so maturin places the compiled `_atlas.*.so` into the
67
+ # python-source tree (atlas-python/python/atlas/), which is where griffe's
68
+ # `paths: ["python"]` search path looks for it. A plain install puts it in
69
+ # site-packages, outside that search path, and the alias fails to resolve.
70
+ # ubuntu-latest ships a Rust toolchain, so maturin can build from source.
71
+ - run: pip install -e ./atlas-python
72
+ - env:
73
+ VERSION: ${{ steps.vars.outputs.version }}
74
+ ALIAS: ${{ steps.vars.outputs.alias }}
75
+ run: |
76
+ git fetch origin gh-pages --depth=1 || true
77
+ # mike rejects an alias identical to the version, so only pass it when it differs
78
+ if [[ "$ALIAS" == "$VERSION" ]]; then
79
+ mike deploy --push --update-aliases -F atlas-python/mkdocs.yml "$VERSION"
80
+ else
81
+ mike deploy --push --update-aliases -F atlas-python/mkdocs.yml "$VERSION" "$ALIAS"
82
+ fi
83
+ # The site root must always redirect to `latest`, so only (re)write the
84
+ # root index.html when this run deploys the `latest` alias (releases).
85
+ # Manual `dev` runs are published at /dev/ but never touch the root.
86
+ if [[ "$ALIAS" == "latest" ]]; then
87
+ mike set-default --push -F atlas-python/mkdocs.yml latest
88
+ fi
@@ -28,4 +28,8 @@ raw_smoke_tests/
28
28
  pyatlas/python/pyatlas/_pyatlas.pyd
29
29
  pyatlas/python/pyatlas/_pyatlas.abi3.so
30
30
  playground/
31
- data/
31
+ data/
32
+ atlas-python/python/atlas/_atlas.pyd
33
+ atlas-python/python/atlas/_atlas.abi3.so
34
+ atlas-python/python/atlas/_atlas*.so
35
+ atlas-python/python/atlas/_atlas.pdb
@@ -83,7 +83,7 @@ Key design points:
83
83
  - **Sync Python API, backed by an internal multi-threaded tokio runtime.** Each blocking call uses `py.allow_threads(|| RT.block_on(...))` so other Python threads can run.
84
84
  - **Type dispatch via macros.** Atlas's read/write methods are generic over dtype (`T: ArrayElement`); the bindings dispatch at runtime via a `numeric_dispatch!` macro in [pyatlas/src/dataset.rs](pyatlas/src/dataset.rs).
85
85
  - **`numpy`-zero-copy on the numeric path.** Python `np.ndarray` ↔ `ndarray::ArrayView` via the `numpy` crate.
86
- - **xarray integration** lives in [pyatlas/python/pyatlas/xarray.py](pyatlas/python/pyatlas/xarray.py). The `Atlas.add_xr_dataset` Rust pymethod and the `ds.atlas.write` accessor both delegate to the `_write_xarray_new_dataset` helper. dask-backed variables are streamed one chunk at a time via `arr.blocks[idx].compute()`.
86
+ - **xarray integration** lives in [pyatlas/python/pyatlas/xarray.py](pyatlas/python/pyatlas/xarray.py). The `Atlas.add_xarray_dataset` Rust pymethod and the `ds.atlas.write` accessor both delegate to the `_write_xarray_new_dataset` helper. dask-backed variables are streamed one chunk at a time via `arr.blocks[idx].compute()`.
87
87
 
88
88
  ---
89
89
 
@@ -28,9 +28,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
28
28
 
29
29
  [[package]]
30
30
  name = "array-format"
31
- version = "0.9.0"
31
+ version = "0.11.0"
32
32
  source = "registry+https://github.com/rust-lang/crates.io-index"
33
- checksum = "d91c6d4d26f667272f8930d41bc95118716e12858baf115940a626510e7b5873"
33
+ checksum = "f894cd59132aa6a00c360fef55469100c39da19c556bbf44a46bd647f7252fae"
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.9.1"
74
+ version = "0.12.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.9.1"
89
+ version = "0.12.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.9.1"
8
+ version = "0.12.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.9.0"
25
+ array-format = "0.11.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.9.1
3
+ Version: 0.12.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Programming Language :: Python :: 3
@@ -118,18 +118,18 @@ ds = xr.Dataset(
118
118
  )
119
119
 
120
120
  with atlas.Atlas.create("/tmp/my_store") as store:
121
- store.add_xr_dataset(ds, "jan_2024") # store-side method
121
+ store.add_xarray_dataset(ds, "jan_2024") # store-side method
122
122
  ds.atlas.write(store, "jan_2025") # xarray accessor (same effect)
123
123
 
124
124
  # Read back as an xr.Dataset
125
125
  store = atlas.Atlas.open("/tmp/my_store")
126
- ds_back = store.to_xarray("jan_2024")
126
+ ds_back = store.open_as_xarray_dataset("jan_2024")
127
127
  xr.testing.assert_identical(ds, ds_back)
128
128
  ```
129
129
 
130
130
  ### Bulk ingestion
131
131
 
132
- `add_xr_dataset` never flushes by itself — N consecutive calls accumulate in memory and a single
132
+ `add_xarray_dataset` never flushes by itself — N consecutive calls accumulate in memory and a single
133
133
  `flush()` (or the `with` exit) persists everything.
134
134
 
135
135
  ```python
@@ -138,35 +138,35 @@ import glob, os, atlas, xarray as xr
138
138
  with atlas.Atlas.create("/tmp/store") as store:
139
139
  for nc_path in sorted(glob.glob("*.nc")):
140
140
  name = os.path.splitext(os.path.basename(nc_path))[0]
141
- store.add_xr_dataset(xr.open_dataset(nc_path), name)
141
+ store.add_xarray_dataset(xr.open_dataset(nc_path), name)
142
142
  # One delta file per array name across the whole batch (not one per file).
143
143
  ```
144
144
 
145
145
  ### Streaming dask-backed writes
146
146
 
147
147
  If a variable's `.data` is a `dask.array.Array` (e.g. from `xr.open_dataset(path, chunks=...)`
148
- or `ds.chunk({...})`), `add_xr_dataset` / `ds.atlas.write` stream **one dask block at a time**
148
+ or `ds.chunk({...})`), `add_xarray_dataset` / `ds.atlas.write` stream **one dask block at a time**
149
149
  into the store rather than materialising the whole array. The dask chunk shape becomes the
150
150
  on-disk `chunk_shape`, so the layout maps 1:1. Peak memory ≈ one chunk per variable.
151
151
 
152
152
  ```python
153
153
  ds = xr.open_dataset("big.nc", chunks={"time": 100, "lat": -1, "lon": -1})
154
154
  with atlas.Atlas.create("/tmp/store") as store:
155
- store.add_xr_dataset(ds, "big") # streams chunk-by-chunk
155
+ store.add_xarray_dataset(ds, "big") # streams chunk-by-chunk
156
156
  ```
157
157
 
158
- Pass `chunks={var: [...]}` to `add_xr_dataset` / `ds.atlas.write` to override the on-disk chunk
158
+ Pass `chunks={var: [...]}` to `add_xarray_dataset` / `ds.atlas.write` to override the on-disk chunk
159
159
  shape independently of dask's chunking.
160
160
 
161
161
  ### Lazy dask-backed reads
162
162
 
163
- `store.to_xarray(name)` returns each variable dask-backed whenever it was stored with non-trivial
163
+ `store.open_as_xarray_dataset(name)` returns each variable dask-backed whenever it was stored with non-trivial
164
164
  chunking (`chunk_shape != shape`); the dask `chunks` tuple mirrors the on-disk chunk grid and each
165
165
  on-disk chunk is one dask task. Full-shape arrays (and 0-D scalars) come back eager as numpy. Call
166
166
  `.compute()` to materialise, or slice / `map_blocks` to operate lazily.
167
167
 
168
168
  ```python
169
- ds_back = atlas.Atlas.open("/tmp/store").to_xarray("big")
169
+ ds_back = atlas.Atlas.open("/tmp/store").open_as_xarray_dataset("big")
170
170
  ds_back["temperature"].data # -> dask.array.Array
171
171
  ds_back["temperature"][0:100].compute() # reads exactly one chunk
172
172
  ```
@@ -179,15 +179,35 @@ 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. |
184
- | Per-variable `_FillValue` | Consumed by `define_array` as a typed fill value (source `Dataset.attrs` is not mutated). |
182
+ | Dataset attrs | Dataset-level (global) attributes, plain keys. |
183
+ | Per-variable attrs | Real per-variable attributes on each variable's array. |
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. |
187
187
 
188
- Each `add_xr_dataset` / `ds.atlas.write` creates a *new* dataset — there is no append-into-existing
188
+ Each `add_xarray_dataset` / `ds.atlas.write` creates a *new* dataset — there is no append-into-existing
189
189
  mode.
190
190
 
191
+ ### Missing data
192
+
193
+ When a dataset is opened with `mask_and_scale=True` (xarray's default), masked cells become `NaN`
194
+ (floats) / `NaT` (datetimes) and `_FillValue` is moved into `var.encoding`. So those cells are
195
+ recorded as **null** (counted in `null_count`, excluded from min/max stats), arrays default to a
196
+ sentinel fill on write: `NaN` for floats, `NaT` for `datetime64[ns]`, and `""` for strings (integers
197
+ have none). Missing string cells (`None`/`NaN`) are substituted with the string fill and a warning is
198
+ emitted, since a string can't be stored as null directly.
199
+
200
+ Override per write with `fill_value` — a bare scalar (numeric arrays) or a `{var: scalar}` dict
201
+ (`None` disables the default for that var); an explicit CF `_FillValue` attribute still takes
202
+ precedence over the default:
203
+
204
+ ```python
205
+ store.add_xarray_dataset(ds, "jan_2024", fill_value={"temperature": -9999.0})
206
+ ```
207
+
208
+ See the [xarray guide](https://github.com/maris-development/atlas/blob/main/atlas-python/docs/guides/xarray.md#fill-values-and-missing-data)
209
+ for the full resolution order.
210
+
191
211
  ## Supported dtypes
192
212
 
193
213
  | numpy dtype | atlas dtype |
@@ -221,8 +241,9 @@ handle instead of a local path. The path-based local-filesystem API works withou
221
241
  | `list_datasets() -> list[str]` | All dataset names. |
222
242
  | `list_arrays() -> list[str]` | Distinct array names across datasets. |
223
243
  | `dataset_exists(name) -> bool` | Existence check. |
224
- | `add_xr_dataset(ds, name, chunks=None)` | Append an `xarray.Dataset` (does **not** flush). |
225
- | `to_xarray(name) -> xr.Dataset` | Read a dataset back (chunked vars come back dask-backed). |
244
+ | `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
+ | `open_as_xarray_dataset(name) -> xr.Dataset` | Read a dataset back (chunked vars come back dask-backed). |
246
+ | `open_as_many_xarray_dataset(names, concat_dim="dataset") -> xr.Dataset` | Open many datasets stacked along `concat_dim` (eager numpy). |
226
247
  | `flush()` | The single durability boundary — persist everything. |
227
248
  | `close()` | Alias for `flush()`; also the `with`-block exit. |
228
249
  | `compact()` | Reclaim tombstoned space across cached array files. |
@@ -240,8 +261,10 @@ handle instead of a local path. The path-based local-filesystem API works withou
240
261
  | `delete_array(name)` | Tombstone the array within this dataset. |
241
262
  | `array_meta(name) -> dict \| None` | `{"dtype", "shape", "chunk_shape", "dimension_names"}`. |
242
263
  | `array_stats(name) -> dict \| None` | `{"row_count", "null_count", "min", "max"}` — populated after `flush()`. |
243
- | `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. |
244
- | `get_attribute(key)` / `attributes()` | Single attribute or dict of all. |
264
+ | `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. |
265
+ | `get_attribute(key)` / `attributes()` | Single global attribute or dict of all. |
266
+ | `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. |
267
+ | `get_array_attribute(array, key)` / `array_attributes(array)` | Single per-variable attribute or dict of all, for `array`. |
245
268
 
246
269
  `DatasetView` does **not** expose its own `flush` / `compact` — both go through the parent `Atlas`.
247
270
 
@@ -250,13 +273,14 @@ handle instead of a local path. The path-based local-filesystem API works withou
250
273
  Runnable, self-contained scripts (each writes to a temp directory):
251
274
 
252
275
  - [01_basics.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/01_basics.py) — create a store, define arrays, set attributes, reopen, read back.
253
- - [02_xarray.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/02_xarray.py) — round-trip an `xr.Dataset` via both `store.add_xr_dataset(...)` and the `ds.atlas.write(...)` accessor.
276
+ - [02_xarray.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/02_xarray.py) — round-trip an `xr.Dataset` via both `store.add_xarray_dataset(...)` and the `ds.atlas.write(...)` accessor.
254
277
  - [03_dask_streaming.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/03_dask_streaming.py) — stream a dask-chunked `xr.Dataset` in one chunk at a time.
278
+ - [09_missing_data.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/09_missing_data.py) — masked/missing cells with default `NaN` / `NaT` / `""` fills, `null_count`, and `fill_value=` overrides.
255
279
 
256
280
  ## Performance
257
281
 
258
282
  ATLAS is tuned for collections of many similarly-shaped datasets. On a "1000 datasets" benchmark
259
- against netCDF4 and Zarr v3, the bulk read paths (`Atlas.to_xarray_many` /
283
+ against netCDF4 and Zarr v3, the bulk read paths (`Atlas.open_as_many_xarray_dataset` /
260
284
  `Atlas.read_array_across_stacked`) beat Zarr by ~2.8× on large chunked slice reads, and on small
261
285
  per-dataset workloads ATLAS leads on both reads and writes. See the
262
286
  [benchmarks](https://github.com/maris-development/atlas/tree/main/atlas-python/benchmarks) for the full
@@ -1,5 +1,12 @@
1
1
  # ATLAS — Aggregated Tensor Large Array Store
2
2
 
3
+ [![CI](https://github.com/maris-development/atlas/actions/workflows/ci.yaml/badge.svg)](https://github.com/maris-development/atlas/actions/workflows/ci.yaml)
4
+ [![crates.io](https://img.shields.io/crates/v/atlas-rust.svg?logo=rust)](https://crates.io/crates/atlas-rust)
5
+ [![docs.rs](https://img.shields.io/docsrs/atlas-rust?logo=docsdotrs&label=docs.rs)](https://docs.rs/atlas-rust)
6
+ [![PyPI](https://img.shields.io/pypi/v/atlas-python.svg?logo=pypi&logoColor=white)](https://pypi.org/project/atlas-python/)
7
+ [![Python docs](https://img.shields.io/badge/docs-atlas--python-blue?logo=materialformkdocs&logoColor=white)](https://maris-development.github.io/atlas/)
8
+ [![License](https://img.shields.io/crates/l/atlas-rust.svg)](LICENSE)
9
+
3
10
  A directory-based store for thousands of named datasets, each holding N-dimensional typed arrays. Built on top of the [`array-format`](https://github.com/robinskil/array-format) (`.af`) binary format, with configurable compression (Zstd, LZ4, or none), chunked I/O, and an [`object_store`](https://crates.io/crates/object_store) backend that works on local disk, S3, GCS, Azure Blob, and in-memory.
4
11
 
5
12
  **Think of it as a "zip" for N-dimensional datasets.** Where a `.zip` bundles many files into one archive, ATLAS gathers many NetCDF / Zarr-style datasets — anything that's a set of named N-dimensional arrays — into a single high-performance collection. But it's more than a bundle: instead of storing each dataset whole, ATLAS lays the data out **variable-first**, so every dataset's `temperature` lives in one file. That makes it cheap to scan or slice a single variable across thousands of datasets at once, while still reading back any individual dataset as a normal NetCDF/xarray-like object.
@@ -14,26 +21,30 @@ A directory-based store for thousands of named datasets, each holding N-dimensio
14
21
 
15
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.
16
23
 
17
- 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.
18
25
 
19
26
  ```text
20
27
  my_store/
21
- ├── atlas.json ← dataset registry and per-dataset attributes (JSON)
28
+ ├── atlas.json ← interned per-dataset schema + attribute-key namespace (JSON)
29
+ ├── _global/
30
+ │ └── data.af ← dataset-level (global) attribute values, one entry per dataset
22
31
  ├── temperature/
23
- │ └── data.af ← one ArrayFile holding temperature for every dataset
32
+ │ └── data.af ← one ArrayFile: temperature + its per-variable attributes, per dataset
24
33
  ├── pressure/
25
34
  │ └── data.af
26
35
  └── time/
27
36
  └── data.af
28
37
  ```
29
38
 
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
+
30
41
  ---
31
42
 
32
43
  ## Durability model
33
44
 
34
- `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.
45
+ `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.
35
46
 
36
- 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).
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).
37
48
 
38
49
  `DatasetView` is a borrowed handle into the atlas's shared meta — it has no `flush()` of its own.
39
50
 
@@ -43,14 +54,25 @@ A single `Atlas::flush()` walks every cached `ArrayFile` (writing deltas + stats
43
54
 
44
55
  ### `atlas.json`
45
56
 
46
- The registry is a plain JSON file written on `Atlas::flush()` / `Atlas::close()`. It stores:
57
+ The schema is a plain JSON file written on `Atlas::flush()` / `Atlas::close()`. It stores:
58
+
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
+ - **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
+ - **Dataset registry** — a map of dataset name → schema-pool index.
62
+ - **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()`.
63
+
64
+ 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`).
65
+
66
+ 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`:
67
+
68
+ | `TypeMismatchPolicy` | Behaviour |
69
+ | --- | --- |
70
+ | `Warn` (default) | Logs a `tracing` warning and carries on — an ingest of many heterogeneous files won't abort because one disagrees. |
71
+ | `Error` | `define_array` / `set_attribute` / `set_array_attribute` return `Error::TypeMismatch`. |
47
72
 
48
- - **Store version** — for future format upgrades.
49
- - **Dataset names** — the complete list of datasets in the store.
50
- - **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.
51
- - **Array schemas** — per array: dtype, shape, chunk shape, named dimensions, and the codec used when the array was first written.
73
+ 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.
52
74
 
53
- 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.
75
+ 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.
54
76
 
55
77
  ### `<array_name>/data.af`
56
78
 
@@ -60,6 +82,7 @@ Each array variable gets its own subdirectory with a single `data.af` binary fil
60
82
  - **Chunked layout** — arrays are split into chunks of a user-specified shape, so partial reads and writes touch only the relevant blocks.
61
83
  - **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.
62
84
  - **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.
85
+ - **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.
63
86
  - **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.
64
87
  - **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.
65
88
 
@@ -257,7 +280,7 @@ Choose LZ4 when decompression throughput matters more than storage size (e.g. la
257
280
 
258
281
  ### Write path
259
282
 
260
- 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_xr_dataset` / `create_dataset` calls amortise to a single flush.
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.
261
284
 
262
285
  ### Compaction
263
286
 
@@ -301,7 +324,7 @@ the `(0:25, 0:25, 0:12)` slice down to chunk-level reads.
301
324
  | **atlas-bulk** | 1 store, `read_array_across_stacked` with slice push-down | **2.12** | 59 | 6387 |
302
325
  | **atlas + `--use-dask`** | 1 store, dask-threaded per-dataset `view.read_arrays(...)` | **3.21** | 60 | 6387 |
303
326
  | zarr | 1000 separate stores, `xr.open_mfdataset(parallel=True).isel(...)` | 5.99 | 38 | 6392 |
304
- | atlas (default) | 1 store, serial per-dataset `to_xarray(...).isel(...).load()` | 10.23 | 51 | 6387 |
327
+ | atlas (default) | 1 store, serial per-dataset `open_as_xarray_dataset(...).isel(...).load()` | 10.23 | 51 | 6387 |
305
328
  | netcdf | 1000 `.nc` files, `xr.open_mfdataset(parallel=True).isel(...)` | 13.91 | 122 | 5596 |
306
329
 
307
330
  #### `--case profile --datasets 1000` (~67 MB raw — per-dataset overhead dominates)
@@ -332,9 +355,9 @@ For tiny-shape datasets the comparison is dominated by per-dataset overhead, whi
332
355
 
333
356
  ### What the numbers say
334
357
 
335
- - **Reads on gridded** (decompression-dominated, with realistic chunking + slice push-down): `atlas-bulk` reads in **2.12s vs zarr's 6.00s — 2.8× faster**. `atlas + --use-dask` (using the new `view.read_arrays(...)` fast path) hits **3.21s — 1.9× faster than zarr**. The win comes from atlas's structural advantage (one open of one file per variable, in-memory metadata) combined with APIs that bypass `to_xarray`'s xr.Dataset + per-chunk dask graph overhead. zarr and netcdf both push the slice down through `open_mfdataset(...).isel(...)` via dask graph optimization — they're not penalised by the chunking; atlas just wins by amortising metadata and skipping per-dataset Python overhead.
358
+ - **Reads on gridded** (decompression-dominated, with realistic chunking + slice push-down): `atlas-bulk` reads in **2.12s vs zarr's 6.00s — 2.8× faster**. `atlas + --use-dask` (using the new `view.read_arrays(...)` fast path) hits **3.21s — 1.9× faster than zarr**. The win comes from atlas's structural advantage (one open of one file per variable, in-memory metadata) combined with APIs that bypass `open_as_xarray_dataset`'s xr.Dataset + per-chunk dask graph overhead. zarr and netcdf both push the slice down through `open_mfdataset(...).isel(...)` via dask graph optimization — they're not penalised by the chunking; atlas just wins by amortising metadata and skipping per-dataset Python overhead.
336
359
  - **Reads on profile** (overhead-dominated): atlas wins by an order of magnitude. `atlas-bulk` is **~50× faster than zarr** (0.08s vs 4.07s) because the per-dataset I/O is small enough that everything is overhead — and atlas's structural design is built for exactly that case. Default serial `atlas` is still ~12× faster than zarr.
337
- - **Default `atlas.to_xarray` iteration is currently SLOW on chunked storage** (10.23s vs zarr's 6.00s). It goes through `to_xarray(name)` which returns dask-backed arrays per chunk (8 chunks/var × 3 vars × 1000 datasets = 24,000 dask delayed tasks just to build the graph). Dask graph overhead exceeds the parallelism win. **The fix is to use `view.read_arrays(vars, start, shape)` for per-dataset slice reads** — that's what `atlas + --use-dask` does internally and why it hits 3.21s. For cross-dataset reads of the *same* slice from many datasets, prefer `Atlas.to_xarray_many` / `read_array_across_stacked` (the `atlas-bulk` row).
360
+ - **Default `atlas.open_as_xarray_dataset` iteration is currently SLOW on chunked storage** (10.23s vs zarr's 6.00s). It goes through `open_as_xarray_dataset(name)` which returns dask-backed arrays per chunk (8 chunks/var × 3 vars × 1000 datasets = 24,000 dask delayed tasks just to build the graph). Dask graph overhead exceeds the parallelism win. **The fix is to use `view.read_arrays(vars, start, shape)` for per-dataset slice reads** — that's what `atlas + --use-dask` does internally and why it hits 3.21s. For cross-dataset reads of the *same* slice from many datasets, prefer `Atlas.open_as_many_xarray_dataset` / `read_array_across_stacked` (the `atlas-bulk` row).
338
361
  - **Workload sensitivity**: `--use-dask` helps when per-dataset decompression is the bottleneck and *hurts* when per-dataset overhead is the bottleneck (profile: default `atlas` 0.32s → dask 2.03s). Picking the right API for the workload matters more than picking dask vs serial.
339
362
  - **Writes on gridded**: zarr is fastest (~31s vs atlas's ~50s) — each `to_zarr(...)` just dumps bytes into a per-dataset subtree, while atlas does more bookkeeping per call (schema registration, per-dataset attrs into the shared `atlas.msgpack.zst`, block addressing in the shared array file). Atlas still beats netcdf decisively.
340
363
  - **Writes on profile**: atlas wins ~12× (0.91s vs zarr's 11.43s, netcdf's 2.98s). At small per-dataset sizes the metadata write dominates, and atlas's amortised single-flush model wins.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "atlas-python"
3
- version = "0.9.1"
3
+ version = "0.12.0"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -86,18 +86,18 @@ ds = xr.Dataset(
86
86
  )
87
87
 
88
88
  with atlas.Atlas.create("/tmp/my_store") as store:
89
- store.add_xr_dataset(ds, "jan_2024") # store-side method
89
+ store.add_xarray_dataset(ds, "jan_2024") # store-side method
90
90
  ds.atlas.write(store, "jan_2025") # xarray accessor (same effect)
91
91
 
92
92
  # Read back as an xr.Dataset
93
93
  store = atlas.Atlas.open("/tmp/my_store")
94
- ds_back = store.to_xarray("jan_2024")
94
+ ds_back = store.open_as_xarray_dataset("jan_2024")
95
95
  xr.testing.assert_identical(ds, ds_back)
96
96
  ```
97
97
 
98
98
  ### Bulk ingestion
99
99
 
100
- `add_xr_dataset` never flushes by itself — N consecutive calls accumulate in memory and a single
100
+ `add_xarray_dataset` never flushes by itself — N consecutive calls accumulate in memory and a single
101
101
  `flush()` (or the `with` exit) persists everything.
102
102
 
103
103
  ```python
@@ -106,35 +106,35 @@ import glob, os, atlas, xarray as xr
106
106
  with atlas.Atlas.create("/tmp/store") as store:
107
107
  for nc_path in sorted(glob.glob("*.nc")):
108
108
  name = os.path.splitext(os.path.basename(nc_path))[0]
109
- store.add_xr_dataset(xr.open_dataset(nc_path), name)
109
+ store.add_xarray_dataset(xr.open_dataset(nc_path), name)
110
110
  # One delta file per array name across the whole batch (not one per file).
111
111
  ```
112
112
 
113
113
  ### Streaming dask-backed writes
114
114
 
115
115
  If a variable's `.data` is a `dask.array.Array` (e.g. from `xr.open_dataset(path, chunks=...)`
116
- or `ds.chunk({...})`), `add_xr_dataset` / `ds.atlas.write` stream **one dask block at a time**
116
+ or `ds.chunk({...})`), `add_xarray_dataset` / `ds.atlas.write` stream **one dask block at a time**
117
117
  into the store rather than materialising the whole array. The dask chunk shape becomes the
118
118
  on-disk `chunk_shape`, so the layout maps 1:1. Peak memory ≈ one chunk per variable.
119
119
 
120
120
  ```python
121
121
  ds = xr.open_dataset("big.nc", chunks={"time": 100, "lat": -1, "lon": -1})
122
122
  with atlas.Atlas.create("/tmp/store") as store:
123
- store.add_xr_dataset(ds, "big") # streams chunk-by-chunk
123
+ store.add_xarray_dataset(ds, "big") # streams chunk-by-chunk
124
124
  ```
125
125
 
126
- Pass `chunks={var: [...]}` to `add_xr_dataset` / `ds.atlas.write` to override the on-disk chunk
126
+ Pass `chunks={var: [...]}` to `add_xarray_dataset` / `ds.atlas.write` to override the on-disk chunk
127
127
  shape independently of dask's chunking.
128
128
 
129
129
  ### Lazy dask-backed reads
130
130
 
131
- `store.to_xarray(name)` returns each variable dask-backed whenever it was stored with non-trivial
131
+ `store.open_as_xarray_dataset(name)` returns each variable dask-backed whenever it was stored with non-trivial
132
132
  chunking (`chunk_shape != shape`); the dask `chunks` tuple mirrors the on-disk chunk grid and each
133
133
  on-disk chunk is one dask task. Full-shape arrays (and 0-D scalars) come back eager as numpy. Call
134
134
  `.compute()` to materialise, or slice / `map_blocks` to operate lazily.
135
135
 
136
136
  ```python
137
- ds_back = atlas.Atlas.open("/tmp/store").to_xarray("big")
137
+ ds_back = atlas.Atlas.open("/tmp/store").open_as_xarray_dataset("big")
138
138
  ds_back["temperature"].data # -> dask.array.Array
139
139
  ds_back["temperature"][0:100].compute() # reads exactly one chunk
140
140
  ```
@@ -147,15 +147,35 @@ 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. |
152
- | Per-variable `_FillValue` | Consumed by `define_array` as a typed fill value (source `Dataset.attrs` is not mutated). |
150
+ | Dataset attrs | Dataset-level (global) attributes, plain keys. |
151
+ | Per-variable attrs | Real per-variable attributes on each variable's array. |
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. |
155
155
 
156
- Each `add_xr_dataset` / `ds.atlas.write` creates a *new* dataset — there is no append-into-existing
156
+ Each `add_xarray_dataset` / `ds.atlas.write` creates a *new* dataset — there is no append-into-existing
157
157
  mode.
158
158
 
159
+ ### Missing data
160
+
161
+ When a dataset is opened with `mask_and_scale=True` (xarray's default), masked cells become `NaN`
162
+ (floats) / `NaT` (datetimes) and `_FillValue` is moved into `var.encoding`. So those cells are
163
+ recorded as **null** (counted in `null_count`, excluded from min/max stats), arrays default to a
164
+ sentinel fill on write: `NaN` for floats, `NaT` for `datetime64[ns]`, and `""` for strings (integers
165
+ have none). Missing string cells (`None`/`NaN`) are substituted with the string fill and a warning is
166
+ emitted, since a string can't be stored as null directly.
167
+
168
+ Override per write with `fill_value` — a bare scalar (numeric arrays) or a `{var: scalar}` dict
169
+ (`None` disables the default for that var); an explicit CF `_FillValue` attribute still takes
170
+ precedence over the default:
171
+
172
+ ```python
173
+ store.add_xarray_dataset(ds, "jan_2024", fill_value={"temperature": -9999.0})
174
+ ```
175
+
176
+ See the [xarray guide](https://github.com/maris-development/atlas/blob/main/atlas-python/docs/guides/xarray.md#fill-values-and-missing-data)
177
+ for the full resolution order.
178
+
159
179
  ## Supported dtypes
160
180
 
161
181
  | numpy dtype | atlas dtype |
@@ -189,8 +209,9 @@ handle instead of a local path. The path-based local-filesystem API works withou
189
209
  | `list_datasets() -> list[str]` | All dataset names. |
190
210
  | `list_arrays() -> list[str]` | Distinct array names across datasets. |
191
211
  | `dataset_exists(name) -> bool` | Existence check. |
192
- | `add_xr_dataset(ds, name, chunks=None)` | Append an `xarray.Dataset` (does **not** flush). |
193
- | `to_xarray(name) -> xr.Dataset` | Read a dataset back (chunked vars come back dask-backed). |
212
+ | `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
+ | `open_as_xarray_dataset(name) -> xr.Dataset` | Read a dataset back (chunked vars come back dask-backed). |
214
+ | `open_as_many_xarray_dataset(names, concat_dim="dataset") -> xr.Dataset` | Open many datasets stacked along `concat_dim` (eager numpy). |
194
215
  | `flush()` | The single durability boundary — persist everything. |
195
216
  | `close()` | Alias for `flush()`; also the `with`-block exit. |
196
217
  | `compact()` | Reclaim tombstoned space across cached array files. |
@@ -208,8 +229,10 @@ handle instead of a local path. The path-based local-filesystem API works withou
208
229
  | `delete_array(name)` | Tombstone the array within this dataset. |
209
230
  | `array_meta(name) -> dict \| None` | `{"dtype", "shape", "chunk_shape", "dimension_names"}`. |
210
231
  | `array_stats(name) -> dict \| None` | `{"row_count", "null_count", "min", "max"}` — populated after `flush()`. |
211
- | `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. |
212
- | `get_attribute(key)` / `attributes()` | Single attribute or dict of all. |
232
+ | `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. |
233
+ | `get_attribute(key)` / `attributes()` | Single global attribute or dict of all. |
234
+ | `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. |
235
+ | `get_array_attribute(array, key)` / `array_attributes(array)` | Single per-variable attribute or dict of all, for `array`. |
213
236
 
214
237
  `DatasetView` does **not** expose its own `flush` / `compact` — both go through the parent `Atlas`.
215
238
 
@@ -218,13 +241,14 @@ handle instead of a local path. The path-based local-filesystem API works withou
218
241
  Runnable, self-contained scripts (each writes to a temp directory):
219
242
 
220
243
  - [01_basics.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/01_basics.py) — create a store, define arrays, set attributes, reopen, read back.
221
- - [02_xarray.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/02_xarray.py) — round-trip an `xr.Dataset` via both `store.add_xr_dataset(...)` and the `ds.atlas.write(...)` accessor.
244
+ - [02_xarray.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/02_xarray.py) — round-trip an `xr.Dataset` via both `store.add_xarray_dataset(...)` and the `ds.atlas.write(...)` accessor.
222
245
  - [03_dask_streaming.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/03_dask_streaming.py) — stream a dask-chunked `xr.Dataset` in one chunk at a time.
246
+ - [09_missing_data.py](https://github.com/maris-development/atlas/blob/main/atlas-python/examples/09_missing_data.py) — masked/missing cells with default `NaN` / `NaT` / `""` fills, `null_count`, and `fill_value=` overrides.
223
247
 
224
248
  ## Performance
225
249
 
226
250
  ATLAS is tuned for collections of many similarly-shaped datasets. On a "1000 datasets" benchmark
227
- against netCDF4 and Zarr v3, the bulk read paths (`Atlas.to_xarray_many` /
251
+ against netCDF4 and Zarr v3, the bulk read paths (`Atlas.open_as_many_xarray_dataset` /
228
252
  `Atlas.read_array_across_stacked`) beat Zarr by ~2.8× on large chunked slice reads, and on small
229
253
  per-dataset workloads ATLAS leads on both reads and writes. See the
230
254
  [benchmarks](https://github.com/maris-development/atlas/tree/main/atlas-python/benchmarks) for the full