atlas-python 0.16.2__tar.gz → 0.16.4__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.
- {atlas_python-0.16.2 → atlas_python-0.16.4}/Cargo.lock +2 -2
- {atlas_python-0.16.2 → atlas_python-0.16.4}/Cargo.toml +1 -1
- {atlas_python-0.16.2 → atlas_python-0.16.4}/PKG-INFO +1 -1
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/Cargo.toml +1 -1
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/cli.md +27 -4
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/creating.md +61 -6
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/dtypes.md +65 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/writer.rs +1 -1
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/test_cli.py +31 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/test_ops.py +268 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/write-path.md +6 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/pyproject.toml +1 -1
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/__init__.pyi +19 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/__main__.py +3 -3
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_cli.py +53 -4
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_ops.py +197 -48
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/xarray.py +209 -3
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/writer/mod.rs +42 -14
- {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/fixtures/from_python/data.atlas +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/integration.rs +42 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/atlas-python-docs.yaml +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/atlas-python-release.yaml +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/atlas-rust-release.yaml +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/ci.yaml +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/.gitignore +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/CONTRIBUTING.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/LICENSE +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/README.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/.python-version +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/LICENSE +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/README.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/examples.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/cloud-storage.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/inspecting.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/reading-data.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/removing.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/index.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/installation.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/quickstart.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/reference/api.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/examples/01_library.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/examples/02_object_store.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/mkdocs.yml +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/attr.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/dtype.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/error.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/lib.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/logging.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/reader.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/runtime.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/source.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/conftest.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/make_fixture.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/test_source.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/README.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/architecture.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/data-model.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/format.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/python.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/read-path.md +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/examples/lifecycle.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/examples/sensor_fleet.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/examples/weather_store.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/__init__.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_log.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_source.py +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/py.typed +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/config.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/error.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/footer.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/mask.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/mod.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/segment_store.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/lib.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/reader/mod.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/array.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/attr.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/dtype.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/mod.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/cross_fixture.rs +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/fixtures/golden_v1/data.atlas +0 -0
- {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/golden.rs +0 -0
|
@@ -71,7 +71,7 @@ dependencies = [
|
|
|
71
71
|
|
|
72
72
|
[[package]]
|
|
73
73
|
name = "atlas-python"
|
|
74
|
-
version = "0.16.
|
|
74
|
+
version = "0.16.4"
|
|
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.16.
|
|
89
|
+
version = "0.16.4"
|
|
90
90
|
dependencies = [
|
|
91
91
|
"array-format",
|
|
92
92
|
"async-trait",
|
|
@@ -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.16.
|
|
8
|
+
version = "0.16.4"
|
|
9
9
|
edition = "2024"
|
|
10
10
|
description = "Single-file immutable store for thousands of N-dimensional datasets, local or on object storage."
|
|
11
11
|
license = "Apache-2.0"
|
|
@@ -25,12 +25,20 @@ Every subcommand takes `--json` and `--log-file PATH`, plus the remote flags
|
|
|
25
25
|
|
|
26
26
|
## Logging
|
|
27
27
|
|
|
28
|
-
`--log-file PATH` appends
|
|
28
|
+
`--log-file PATH` appends to the file you name. There is no default location,
|
|
29
|
+
and no log at all without the flag. The command prints the absolute path it
|
|
30
|
+
opened, so it is never a guess:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
atlas: logging to /home/you/ingest.log
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The file gets every error and warning, with the reason:
|
|
29
37
|
|
|
30
38
|
```bash
|
|
31
39
|
$ atlas create /data/nc /data/collection --skip-unsupported --log-file ingest.log
|
|
32
40
|
$ cat ingest.log
|
|
33
|
-
2026-09-01 14:30:41 INFO atlas.cli: atlas 0.16.
|
|
41
|
+
2026-09-01 14:30:41 INFO atlas.cli: atlas 0.16.4: create /data/nc ...
|
|
34
42
|
2026-09-01 14:30:41 INFO atlas.ops: ingesting 2 file(s) into /data/collection
|
|
35
43
|
2026-09-01 14:30:41 WARNING atlas.ops: /data/nc/buoy.nc: skipped array 'flag' of dtype bool: numpy dtype dtype('bool') is not supported by atlas (supported: ...)
|
|
36
44
|
2026-09-01 14:30:41 INFO atlas.ops: wrote 1 dataset(s); skipped 0 file(s) and 1 array(s)
|
|
@@ -66,10 +74,25 @@ A failure part-way leaves no collection, and not a partial one.
|
|
|
66
74
|
| `--open-chunks MODE` | How files are read: `auto`, `native`, `none`, or a JSON dict |
|
|
67
75
|
| `--chunks JSON` | Override the stored chunk shape, `'{"temperature": [64, 64]}'` |
|
|
68
76
|
| `--skip-errors` | Skip files that fail instead of abandoning the collection |
|
|
77
|
+
| `-j`, `--workers N` | Stage N files at once. About 3x on a many-core machine. Ordinals do not move |
|
|
78
|
+
| `--convert-calendar` | Turn a cftime axis into exact Gregorian timestamps, each keeping its instant |
|
|
79
|
+
| `--no-decode-times` | Keep a time axis as raw numbers, for a calendar that decodes to cftime |
|
|
69
80
|
| `--skip-unsupported` | Leave out an array of an unsupported dtype, and keep the rest of the dataset |
|
|
70
81
|
| `-q`, `--quiet` | Do not list a file as it lands |
|
|
71
82
|
|
|
72
|
-
Progress goes to stderr, so a pipe still reads stdout.
|
|
83
|
+
Progress goes to stderr, so a pipe still reads stdout. Each line counts the
|
|
84
|
+
files and says how many remain:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
Writing /data/collection from 3 file(s)
|
|
88
|
+
[1/3] 2024-01.nc (2 left)
|
|
89
|
+
[2/3] 2024-02.nc (1 left)
|
|
90
|
+
[3/3] 2024-03.nc (0 left)
|
|
91
|
+
3 dataset(s) written to /data/collection
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`-q` turns the per-file lines off. The same counter goes to `--log-file`, for
|
|
95
|
+
a run nobody watched.
|
|
73
96
|
|
|
74
97
|
### Unsupported dtypes
|
|
75
98
|
|
|
@@ -109,7 +132,7 @@ size. `--open-chunks` picks another strategy:
|
|
|
109
132
|
|
|
110
133
|
| Mode | Reads | Stored chunk shape |
|
|
111
134
|
|---|---|---|
|
|
112
|
-
| `auto` *(default)* | blocks sized to `--chunk-size` | those blocks |
|
|
135
|
+
| `auto` *(default)* | whole when the file is small, else blocks sized to `--chunk-size` | those blocks |
|
|
113
136
|
| `native` | the file's own chunk encoding | that encoding |
|
|
114
137
|
| `none` | each variable whole | one full-shape chunk |
|
|
115
138
|
| JSON dict | as given, per dimension | as given |
|
|
@@ -140,8 +140,10 @@ that stream to stderr. See [Installation](../installation.md).
|
|
|
140
140
|
These are one decision. The blocks a file *reads* in are the chunks it
|
|
141
141
|
*stores* in.
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
`open_chunks="auto"` is the default, and it picks a strategy per file. A file
|
|
144
|
+
well inside the block budget opens whole. The dask graph costs about 40 ms
|
|
145
|
+
per file, and a small variable lands as one chunk either way. A larger
|
|
146
|
+
file streams through dask, so memory stays bounded. Three results follow:
|
|
145
147
|
|
|
146
148
|
- A file far larger than memory streams block by block, and does not read
|
|
147
149
|
whole.
|
|
@@ -158,13 +160,14 @@ atlas.create("/data/nc", dest, chunk_size="64MiB")
|
|
|
158
160
|
```
|
|
159
161
|
|
|
160
162
|
`chunk_size` is about the memory ceiling per variable. Lower it on a small
|
|
161
|
-
machine. Raise it for larger stored chunks.
|
|
163
|
+
machine. Raise it for larger stored chunks. It also moves the threshold at
|
|
164
|
+
which `"auto"` stops opening a file whole.
|
|
162
165
|
|
|
163
166
|
### How files are opened
|
|
164
167
|
|
|
165
168
|
| `open_chunks` | Reads | Stored chunk shape |
|
|
166
169
|
|---|---|---|
|
|
167
|
-
| `"auto"` *(default)* | blocks sized to `chunk_size` | those blocks |
|
|
170
|
+
| `"auto"` *(default)* | whole when small, else blocks sized to `chunk_size` | those blocks |
|
|
168
171
|
| `"native"` | the file's own chunk encoding | that encoding |
|
|
169
172
|
| `None` | each variable whole | one full-shape chunk |
|
|
170
173
|
| `{"time": 100}` | as given, per dimension | as given |
|
|
@@ -206,6 +209,51 @@ arrays["temperature"]["chunk_shape"]
|
|
|
206
209
|
atlas show dest 2024-01.nc | grep _ChunkShape
|
|
207
210
|
```
|
|
208
211
|
|
|
212
|
+
### Speed on many small files
|
|
213
|
+
|
|
214
|
+
A directory of small files spends most of its time per file, not per byte. On
|
|
215
|
+
213 KiB profile files the default runs at about 30 files per second, or five
|
|
216
|
+
minutes for ten thousand.
|
|
217
|
+
|
|
218
|
+
**`--workers N` stages N files at once.** It is the largest single win:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
atlas create /data/nc /data/collection --workers 4
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
workers=1 : 32 files/s 1.00x
|
|
226
|
+
workers=2 : 84 files/s 2.65x
|
|
227
|
+
workers=4 : 120 files/s 3.77x
|
|
228
|
+
workers=8 : 118 files/s 3.72x <- plateau
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The commit is the costly half of an ingest, about two thirds of it. It is pure
|
|
232
|
+
Rust, holds no lock until its append, and releases the GIL, so it overlaps.
|
|
233
|
+
The reads stay on the calling thread, and they are what flattens the curve
|
|
234
|
+
near four.
|
|
235
|
+
|
|
236
|
+
The reads have to stay there. netCDF4 sits on HDF5, which is not thread safe,
|
|
237
|
+
and xarray locks an array read but not a variable open. Two threads inside
|
|
238
|
+
`open_dataset` crash the process.
|
|
239
|
+
|
|
240
|
+
Nothing else changes. `add_dataset` runs in file order, so every ordinal
|
|
241
|
+
matches a sequential build. The summary sorts back into file order too. Only
|
|
242
|
+
`progress` reports in completion order.
|
|
243
|
+
|
|
244
|
+
Two more settings matter when that is still too slow:
|
|
245
|
+
|
|
246
|
+
- **`--open-chunks native`** forces dask on every file. That costs about twice
|
|
247
|
+
as long on a small file. Use it only when the file's own chunking is what
|
|
248
|
+
you want stored.
|
|
249
|
+
- **`--codec none`** trades size for speed. It saved about 20 percent on the
|
|
250
|
+
same files.
|
|
251
|
+
|
|
252
|
+
Past the plateau, run several `atlas create` commands at once, one per part of
|
|
253
|
+
the tree. Each has its own writer, so nothing serialises between them. That
|
|
254
|
+
reached 3.7x on eight processes where threads reached 2.9x. The cost is one
|
|
255
|
+
collection per part.
|
|
256
|
+
|
|
209
257
|
### The writer's own memory
|
|
210
258
|
|
|
211
259
|
Staging runs on local disk. `array-format` spills each compressed chunk to a
|
|
@@ -233,8 +281,15 @@ used.
|
|
|
233
281
|
atlas.create("/data/nc", dest, progress=lambda name: print(name))
|
|
234
282
|
```
|
|
235
283
|
|
|
236
|
-
The CLI does this by default, to stderr, so a pipe still reads stdout.
|
|
237
|
-
|
|
284
|
+
The CLI does this by default, to stderr, so a pipe still reads stdout. Each
|
|
285
|
+
line counts the files and says how many remain:
|
|
286
|
+
|
|
287
|
+
```text
|
|
288
|
+
[ 12/10000] 000043_CFPOINT_3593_V0.nc (9988 left)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`-q` turns the per-file lines off. Pass `--log-file PATH` to keep the same
|
|
292
|
+
counter in a file. The command prints the absolute path it opened.
|
|
238
293
|
|
|
239
294
|
## What can go wrong
|
|
240
295
|
|
|
@@ -17,6 +17,71 @@ What a NetCDF variable becomes when `atlas create` ingests it.
|
|
|
17
17
|
`atlas show` prints the atlas name, so a `datetime64[ns]` variable appears as
|
|
18
18
|
`timestamp_nanoseconds`.
|
|
19
19
|
|
|
20
|
+
### Calendars that decode to cftime
|
|
21
|
+
|
|
22
|
+
xarray decodes a time axis to `datetime64[ns]` only when the calendar allows
|
|
23
|
+
it. A Julian, `360_day`, or `noleap` calendar, or a date outside the
|
|
24
|
+
`datetime64[ns]` range, decodes to a `cftime` object instead. Those arrive as
|
|
25
|
+
a numpy `object` array, and atlas cannot store one:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
atlas: profile.nc: variable 'JULD' holds cftime objects (DatetimeJulian),
|
|
29
|
+
which atlas cannot store...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Atlas refuses by default rather than guess, because two conversions exist and
|
|
33
|
+
they differ by 13 days. Pick one.
|
|
34
|
+
|
|
35
|
+
**Convert to the exact instant.** This is almost always what you want:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
atlas create /data/nc /data/collection --convert-calendar
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
atlas.create("/data/nc", dest, convert_calendar=True)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The axis becomes `timestamp_nanoseconds`, and every value keeps the moment it
|
|
46
|
+
named. A Julian `1973-02-25 18:15` becomes the Gregorian `1973-03-10 18:15`.
|
|
47
|
+
Those are one instant under two calendars. cftime maps between them through
|
|
48
|
+
the Julian Day, so nothing is approximated.
|
|
49
|
+
|
|
50
|
+
**Keep the raw numbers.** The axis stores as an integer, with its `units` and
|
|
51
|
+
`calendar` attributes beside it. A reader decodes it later with
|
|
52
|
+
`cftime.num2date`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
atlas create /data/nc /data/collection --no-decode-times
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Drop the axis.** `--skip-unsupported` leaves the time array out, and keeps
|
|
59
|
+
the rest of the dataset.
|
|
60
|
+
|
|
61
|
+
#### Two conversions, 13 days apart
|
|
62
|
+
|
|
63
|
+
The Julian calendar takes a leap year every four years. The Gregorian calendar
|
|
64
|
+
drops three of those every four centuries. The gap grows by about three days
|
|
65
|
+
per 400 years, and holds at 13 days from 1900 to 2100.
|
|
66
|
+
|
|
67
|
+
| Conversion | Julian 2024-01-01 becomes | It keeps |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `--convert-calendar` | 2024-01-14 | the instant |
|
|
70
|
+
| `xr.Dataset.convert_calendar("standard")` | 2024-01-01 | the labels |
|
|
71
|
+
|
|
72
|
+
The xarray method keeps the year, month, and day, which moves the moment by 13
|
|
73
|
+
days. Use it only when the labels matter more than the instant.
|
|
74
|
+
|
|
75
|
+
#### Two limits
|
|
76
|
+
|
|
77
|
+
**An artificial calendar names no instant.** A `360_day`, `noleap`, or
|
|
78
|
+
`all_leap` year is a model convention with no place on a real timeline.
|
|
79
|
+
`--convert-calendar` raises for one. Use `--no-decode-times` instead.
|
|
80
|
+
|
|
81
|
+
**A nanosecond timestamp spans 1677-09-21 to 2262-04-11.** A date outside that
|
|
82
|
+
raises. numpy wraps such a date in silence, so atlas checks every value before
|
|
83
|
+
it stores one.
|
|
84
|
+
|
|
20
85
|
### datetime and timedelta
|
|
21
86
|
|
|
22
87
|
Atlas supports the `[ns]` resolution of `datetime64` alone. It rejects every
|
|
@@ -322,7 +322,7 @@ impl PyDatasetWriter {
|
|
|
322
322
|
String::from_utf8_lossy(&b).into_owned()
|
|
323
323
|
} else {
|
|
324
324
|
return Err(PyTypeError::new_err(format!(
|
|
325
|
-
"string
|
|
325
|
+
"array {name:?}: a string element must be str or bytes, got {:?}",
|
|
326
326
|
bound.get_type().name()?
|
|
327
327
|
)));
|
|
328
328
|
};
|
|
@@ -186,6 +186,37 @@ def test_create_descends_by_default_and_no_recursive_opts_out(capsys, tmp_path):
|
|
|
186
186
|
assert code == 0, err
|
|
187
187
|
|
|
188
188
|
|
|
189
|
+
def test_create_counts_progress_and_what_is_left(capsys, netcdf_dir, tmp_path):
|
|
190
|
+
code, _, err = run(capsys, "create", str(netcdf_dir), str(tmp_path / "c"))
|
|
191
|
+
assert code == 0
|
|
192
|
+
assert "from 3 file(s)" in err
|
|
193
|
+
assert "[1/3] 2024-01.nc (2 left)" in err
|
|
194
|
+
assert "[3/3] 2024-03.nc (0 left)" in err
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_log_file_says_where_it_writes(capsys, netcdf_dir, tmp_path):
|
|
198
|
+
log = tmp_path / "run.log"
|
|
199
|
+
code, _, err = run(
|
|
200
|
+
capsys, "create", str(netcdf_dir), str(tmp_path / "c"), "-q",
|
|
201
|
+
"--log-file", str(log),
|
|
202
|
+
)
|
|
203
|
+
assert code == 0
|
|
204
|
+
assert f"logging to {log}" in err
|
|
205
|
+
# The log carries the same counter, for a run nobody watched.
|
|
206
|
+
text = log.read_text()
|
|
207
|
+
assert "[1/3] wrote 2024-01.nc" in text
|
|
208
|
+
assert "[3/3] wrote 2024-03.nc" in text
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_workers_flag_builds_the_same_collection(capsys, netcdf_dir, tmp_path):
|
|
212
|
+
run(capsys, "create", str(netcdf_dir), str(tmp_path / "one"), "-q")
|
|
213
|
+
run(capsys, "create", str(netcdf_dir), str(tmp_path / "many"), "-q", "-j", "4")
|
|
214
|
+
|
|
215
|
+
code, a, _ = run(capsys, "ls", str(tmp_path / "one"))
|
|
216
|
+
code, b, _ = run(capsys, "ls", str(tmp_path / "many"))
|
|
217
|
+
assert a.split() == b.split() == ["2024-01.nc", "2024-02.nc", "2024-03.nc"]
|
|
218
|
+
|
|
219
|
+
|
|
189
220
|
# ── ls ───────────────────────────────────────────────────────────────
|
|
190
221
|
|
|
191
222
|
|
|
@@ -148,6 +148,105 @@ def test_every_codec_round_trips(netcdf_dir, tmp_path, codec):
|
|
|
148
148
|
assert len(atlas.list_datasets(str(dest))) == 3
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
# ── parallel staging ─────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.fixture
|
|
155
|
+
def many_netcdf_files(tmp_path):
|
|
156
|
+
"""Enough files that several workers actually overlap."""
|
|
157
|
+
d = tmp_path / "many"
|
|
158
|
+
d.mkdir()
|
|
159
|
+
for i in range(12):
|
|
160
|
+
make_dataset((i % 12) + 1).to_netcdf(d / f"m{i:03d}.nc")
|
|
161
|
+
return d
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.parametrize("workers", [2, 4])
|
|
165
|
+
def test_workers_keep_the_ordinals_of_a_sequential_build(
|
|
166
|
+
many_netcdf_files, tmp_path, workers
|
|
167
|
+
):
|
|
168
|
+
"""add_dataset runs in file order, so a worker cannot move an ordinal."""
|
|
169
|
+
one = tmp_path / "one"
|
|
170
|
+
many = tmp_path / f"many{workers}"
|
|
171
|
+
atlas.create(many_netcdf_files, str(one))
|
|
172
|
+
atlas.create(many_netcdf_files, str(many), workers=workers)
|
|
173
|
+
|
|
174
|
+
assert atlas.list_datasets(str(one)) == atlas.list_datasets(str(many))
|
|
175
|
+
for name in atlas.list_datasets(str(one)):
|
|
176
|
+
assert (
|
|
177
|
+
atlas.describe(str(one), name)["ordinal"]
|
|
178
|
+
== atlas.describe(str(many), name)["ordinal"]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_workers_produce_the_same_collection(many_netcdf_files, tmp_path):
|
|
183
|
+
one = tmp_path / "one"
|
|
184
|
+
many = tmp_path / "many"
|
|
185
|
+
r1 = atlas.create(many_netcdf_files, str(one))
|
|
186
|
+
r4 = atlas.create(many_netcdf_files, str(many), workers=4)
|
|
187
|
+
|
|
188
|
+
assert r1["written"] == r4["written"]
|
|
189
|
+
a, b = atlas.info(str(one)), atlas.info(str(many))
|
|
190
|
+
assert a["array_stats"] == b["array_stats"]
|
|
191
|
+
assert a["dataset_count"] == b["dataset_count"]
|
|
192
|
+
assert a["interned_schemas"] == b["interned_schemas"]
|
|
193
|
+
|
|
194
|
+
# Every dataset still reads back with its own values.
|
|
195
|
+
for name in atlas.list_datasets(str(many)):
|
|
196
|
+
assert atlas.describe(str(many), name)["attributes"] == atlas.describe(
|
|
197
|
+
str(one), name
|
|
198
|
+
)["attributes"]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_workers_still_stop_on_a_bad_file(many_netcdf_files, tmp_path):
|
|
202
|
+
xr.Dataset({"flag": xr.DataArray(np.array([True]), dims=["x"])}).to_netcdf(
|
|
203
|
+
many_netcdf_files / "bad.nc"
|
|
204
|
+
)
|
|
205
|
+
with pytest.raises(atlas.AtlasError):
|
|
206
|
+
atlas.create(many_netcdf_files, str(tmp_path / "c"), workers=4)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_workers_can_skip_a_bad_file(many_netcdf_files, tmp_path):
|
|
210
|
+
xr.Dataset({"flag": xr.DataArray(np.array([True]), dims=["x"])}).to_netcdf(
|
|
211
|
+
many_netcdf_files / "bad.nc"
|
|
212
|
+
)
|
|
213
|
+
result = atlas.create(
|
|
214
|
+
many_netcdf_files, str(tmp_path / "c"), workers=4, on_error="skip"
|
|
215
|
+
)
|
|
216
|
+
assert [s["file"].endswith("bad.nc") for s in result["skipped"]] == [True]
|
|
217
|
+
assert len(result["written"]) == 12
|
|
218
|
+
# The order of what landed still follows the input.
|
|
219
|
+
assert atlas.list_datasets(str(tmp_path / "c")) == sorted(result["written"])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_every_netcdf_read_stays_on_one_thread(many_netcdf_files, tmp_path, monkeypatch):
|
|
223
|
+
"""netCDF4 sits on HDF5, which is not thread safe.
|
|
224
|
+
|
|
225
|
+
xarray locks an array read but not a variable open, so two threads inside
|
|
226
|
+
`open_dataset` segfault. Reads therefore stay on the calling thread, and
|
|
227
|
+
only the commit goes to the pool.
|
|
228
|
+
"""
|
|
229
|
+
import threading
|
|
230
|
+
|
|
231
|
+
seen: set[int] = set()
|
|
232
|
+
original = xr.open_dataset
|
|
233
|
+
|
|
234
|
+
def spy(*args, **kwargs):
|
|
235
|
+
seen.add(threading.get_ident())
|
|
236
|
+
return original(*args, **kwargs)
|
|
237
|
+
|
|
238
|
+
monkeypatch.setattr(xr, "open_dataset", spy)
|
|
239
|
+
atlas.create(many_netcdf_files, str(tmp_path / "c"), workers=4)
|
|
240
|
+
|
|
241
|
+
assert len(seen) == 1, f"netCDF was read from {len(seen)} threads"
|
|
242
|
+
assert seen == {threading.get_ident()}, "a read left the calling thread"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_workers_below_one_is_refused(netcdf_dir, tmp_path):
|
|
246
|
+
with pytest.raises(atlas.AtlasError, match="workers must be at least 1"):
|
|
247
|
+
atlas.create(netcdf_dir, str(tmp_path / "c"), workers=0)
|
|
248
|
+
|
|
249
|
+
|
|
151
250
|
# ── unsupported dtypes ───────────────────────────────────────────────
|
|
152
251
|
|
|
153
252
|
|
|
@@ -215,6 +314,151 @@ def test_a_clean_ingest_reports_no_skipped_arrays(netcdf_dir, tmp_path):
|
|
|
215
314
|
assert result["skipped_arrays"] == []
|
|
216
315
|
|
|
217
316
|
|
|
317
|
+
# ── cftime calendars ─────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.fixture
|
|
321
|
+
def netcdf_dir_with_a_julian_calendar(tmp_path):
|
|
322
|
+
"""A time axis xarray decodes to cftime, not to datetime64."""
|
|
323
|
+
import cftime
|
|
324
|
+
|
|
325
|
+
d = tmp_path / "nc"
|
|
326
|
+
d.mkdir()
|
|
327
|
+
times = [cftime.DatetimeJulian(2024, 1, i + 1) for i in range(4)]
|
|
328
|
+
xr.Dataset(
|
|
329
|
+
{"temp": ("time", np.arange(4, dtype=np.float32))},
|
|
330
|
+
coords={"time": ("time", times)},
|
|
331
|
+
).to_netcdf(d / "julian.nc")
|
|
332
|
+
return d
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_a_cftime_axis_reports_the_variable_and_the_remedy(
|
|
336
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
337
|
+
):
|
|
338
|
+
with pytest.raises(atlas.AtlasError) as exc:
|
|
339
|
+
atlas.create(netcdf_dir_with_a_julian_calendar, str(tmp_path / "c"))
|
|
340
|
+
|
|
341
|
+
message = str(exc.value)
|
|
342
|
+
assert "'time'" in message, "the message must name the variable"
|
|
343
|
+
assert "DatetimeJulian" in message
|
|
344
|
+
assert "decode_times=False" in message
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_decode_times_false_stores_a_cftime_axis_as_raw_numbers(
|
|
348
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
349
|
+
):
|
|
350
|
+
dest = tmp_path / "c"
|
|
351
|
+
atlas.create(netcdf_dir_with_a_julian_calendar, str(dest), decode_times=False)
|
|
352
|
+
|
|
353
|
+
arrays = {a["name"]: a for a in atlas.describe(str(dest), "julian.nc")["arrays"]}
|
|
354
|
+
assert arrays["time"]["dtype"] == "int64"
|
|
355
|
+
# The units and the calendar survive, so a reader can decode the numbers.
|
|
356
|
+
assert arrays["time"]["attributes"]["calendar"] == "julian"
|
|
357
|
+
assert "days since" in arrays["time"]["attributes"]["units"]
|
|
358
|
+
assert arrays["temp"]["dtype"] == "float32"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_a_cftime_axis_can_be_skipped_per_array(
|
|
362
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
363
|
+
):
|
|
364
|
+
dest = tmp_path / "c"
|
|
365
|
+
result = atlas.create(
|
|
366
|
+
netcdf_dir_with_a_julian_calendar, str(dest), on_unsupported="skip"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
assert [s["array"] for s in result["skipped_arrays"]] == ["time"]
|
|
370
|
+
assert [a["name"] for a in atlas.describe(str(dest), "julian.nc")["arrays"]] == [
|
|
371
|
+
"temp"
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_convert_calendar_keeps_the_exact_instant(
|
|
376
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
377
|
+
):
|
|
378
|
+
"""A Julian label and its Gregorian label name one moment, 13 days apart."""
|
|
379
|
+
dest = tmp_path / "c"
|
|
380
|
+
atlas.create(netcdf_dir_with_a_julian_calendar, str(dest), convert_calendar=True)
|
|
381
|
+
|
|
382
|
+
arrays = {a["name"]: a for a in atlas.describe(str(dest), "julian.nc")["arrays"]}
|
|
383
|
+
assert arrays["time"]["dtype"] == "timestamp_nanoseconds"
|
|
384
|
+
|
|
385
|
+
# The fixture holds Julian 2024-01-01 through 2024-01-04. The same instants
|
|
386
|
+
# carry the Gregorian labels 2024-01-14 through 2024-01-17.
|
|
387
|
+
first = np.datetime64(arrays["time"]["stats"]["min"], "ns")
|
|
388
|
+
last = np.datetime64(arrays["time"]["stats"]["max"], "ns")
|
|
389
|
+
assert first == np.datetime64("2024-01-14T00:00:00")
|
|
390
|
+
assert last == np.datetime64("2024-01-17T00:00:00")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_convert_calendar_matches_a_per_element_conversion(
|
|
394
|
+
netcdf_dir_with_a_julian_calendar,
|
|
395
|
+
):
|
|
396
|
+
"""The array-at-once conversion must equal the obvious slow one."""
|
|
397
|
+
from atlas.xarray import _cftime_to_datetime64
|
|
398
|
+
|
|
399
|
+
ds = xr.open_dataset(netcdf_dir_with_a_julian_calendar / "julian.nc")
|
|
400
|
+
values = np.asarray(ds["time"].values)
|
|
401
|
+
|
|
402
|
+
fast = _cftime_to_datetime64("time", values)
|
|
403
|
+
slow = np.array(
|
|
404
|
+
[
|
|
405
|
+
np.datetime64(v.change_calendar("proleptic_gregorian").isoformat(), "ns")
|
|
406
|
+
for v in values
|
|
407
|
+
]
|
|
408
|
+
)
|
|
409
|
+
assert np.array_equal(fast, slow)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_an_artificial_calendar_has_no_exact_gregorian_date(tmp_path):
|
|
413
|
+
"""A 360_day year names no real instant, so no conversion exists."""
|
|
414
|
+
import cftime
|
|
415
|
+
|
|
416
|
+
from atlas.xarray import _cftime_to_datetime64
|
|
417
|
+
|
|
418
|
+
values = np.array([cftime.Datetime360Day(2024, 2, 30)], dtype=object)
|
|
419
|
+
with pytest.raises(NotImplementedError, match="360_day"):
|
|
420
|
+
_cftime_to_datetime64("time", values)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_a_date_outside_the_nanosecond_range_is_refused_not_wrapped(tmp_path):
|
|
424
|
+
"""numpy wraps such a date in silence. Atlas must not pass one on."""
|
|
425
|
+
import cftime
|
|
426
|
+
|
|
427
|
+
from atlas.xarray import _cftime_to_datetime64
|
|
428
|
+
|
|
429
|
+
for year in (1600, 2300):
|
|
430
|
+
values = np.array([cftime.DatetimeJulian(year, 1, 1)], dtype=object)
|
|
431
|
+
with pytest.raises(NotImplementedError, match="outside the range"):
|
|
432
|
+
_cftime_to_datetime64("time", values)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_convert_calendar_leaves_a_normal_time_axis_alone(netcdf_dir, tmp_path):
|
|
436
|
+
dest = tmp_path / "c"
|
|
437
|
+
atlas.create(netcdf_dir, str(dest), convert_calendar=True)
|
|
438
|
+
arrays = {a["name"]: a for a in atlas.describe(str(dest), "2024-01.nc")["arrays"]}
|
|
439
|
+
assert arrays["time"]["dtype"] == "timestamp_nanoseconds"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_an_object_array_of_something_else_is_refused_clearly(tmp_path):
|
|
443
|
+
"""numpy reports `object` for both a string array and this one."""
|
|
444
|
+
from atlas.xarray import _reject_unstorable_object_array
|
|
445
|
+
|
|
446
|
+
var = xr.DataArray(np.array([{"a": 1}, {"b": 2}], dtype=object), dims=["x"])
|
|
447
|
+
with pytest.raises(NotImplementedError, match="object array of dict"):
|
|
448
|
+
_reject_unstorable_object_array("payload", var)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_a_real_string_array_still_passes_the_object_check(tmp_path):
|
|
452
|
+
from atlas.xarray import _reject_unstorable_object_array
|
|
453
|
+
|
|
454
|
+
# A missing cell must not be mistaken for an unsupported element.
|
|
455
|
+
var = xr.DataArray(np.array([None, "b", "c"], dtype=object), dims=["x"])
|
|
456
|
+
_reject_unstorable_object_array("label", var)
|
|
457
|
+
|
|
458
|
+
empty = xr.DataArray(np.array([], dtype=object), dims=["x"])
|
|
459
|
+
_reject_unstorable_object_array("empty", empty)
|
|
460
|
+
|
|
461
|
+
|
|
218
462
|
# ── the log file ─────────────────────────────────────────────────────
|
|
219
463
|
|
|
220
464
|
|
|
@@ -329,6 +573,30 @@ def test_chunk_size_controls_the_stored_chunk_shape(tmp_path):
|
|
|
329
573
|
assert large == [1024, 1024]
|
|
330
574
|
|
|
331
575
|
|
|
576
|
+
def test_auto_opens_a_small_file_whole_and_a_large_one_in_blocks(tmp_path):
|
|
577
|
+
"""`auto` picks a strategy per file. dask costs more than it saves on a
|
|
578
|
+
small one, and the stored layout comes out the same either way."""
|
|
579
|
+
from atlas._ops import _open_kwargs_for
|
|
580
|
+
|
|
581
|
+
budget = 128 * 1024 * 1024
|
|
582
|
+
base = {"chunks": "auto"}
|
|
583
|
+
|
|
584
|
+
small = tmp_path / "small.nc"
|
|
585
|
+
small.write_bytes(b"x" * 1024)
|
|
586
|
+
assert "chunks" not in _open_kwargs_for(small, "auto", base, budget)
|
|
587
|
+
|
|
588
|
+
large = tmp_path / "large.nc"
|
|
589
|
+
large.write_bytes(b"x" * (64 * 1024 * 1024))
|
|
590
|
+
assert _open_kwargs_for(large, "auto", base, budget) == base
|
|
591
|
+
|
|
592
|
+
# Every other mode is an explicit choice, and passes through untouched.
|
|
593
|
+
assert _open_kwargs_for(small, "native", {"chunks": {}}, budget) == {"chunks": {}}
|
|
594
|
+
assert _open_kwargs_for(small, None, {}, budget) == {}
|
|
595
|
+
|
|
596
|
+
# A smaller budget pulls the threshold down with it.
|
|
597
|
+
assert _open_kwargs_for(small, "auto", base, 1024) == base
|
|
598
|
+
|
|
599
|
+
|
|
332
600
|
def test_small_files_still_land_as_a_single_chunk(netcdf_dir, tmp_path):
|
|
333
601
|
"""Auto chunking must not split an array that fits with room to spare."""
|
|
334
602
|
atlas.create(netcdf_dir, str(tmp_path / "c"))
|
|
@@ -97,6 +97,12 @@ Concurrent datasets therefore land in finish order, and never interleave their
|
|
|
97
97
|
bytes. `tests/integration.rs` asserts that the segments still tile the
|
|
98
98
|
container without a gap under concurrent staging.
|
|
99
99
|
|
|
100
|
+
**Ordinals do not follow that order.** Each dataset carries the number of the
|
|
101
|
+
`add_dataset` call that opened it. `AtlasWriter::finish` then sorts the footer
|
|
102
|
+
entries on that number. Stage a directory twice and every dataset lands at the same
|
|
103
|
+
ordinal, however many threads did the work. Each entry holds its own byte
|
|
104
|
+
range, so the segments need no matching order on disk.
|
|
105
|
+
|
|
100
106
|
## Failure
|
|
101
107
|
|
|
102
108
|
| What happens | Result |
|
|
@@ -62,6 +62,9 @@ def create(
|
|
|
62
62
|
chunks: Optional[dict[str, Sequence[int]]] = None,
|
|
63
63
|
open_chunks: Union[str, dict[str, int], None] = "auto",
|
|
64
64
|
chunk_size: str = "128MiB",
|
|
65
|
+
decode_times: bool = True,
|
|
66
|
+
convert_calendar: bool = False,
|
|
67
|
+
workers: int = 1,
|
|
65
68
|
on_error: str = "stop",
|
|
66
69
|
on_unsupported: str = "stop",
|
|
67
70
|
progress: Optional[Callable[[str], None]] = None,
|
|
@@ -114,6 +117,22 @@ def create(
|
|
|
114
117
|
chunk_size: The block size ``"auto"`` aims at, as a dask size string.
|
|
115
118
|
It is about the memory ceiling per variable during ingest. It
|
|
116
119
|
defaults to ``"128MiB"``.
|
|
120
|
+
decode_times: How xarray reads a time axis. Under the default, a
|
|
121
|
+
calendar it cannot map to ``datetime64[ns]``, such as a Julian
|
|
122
|
+
one, decodes to cftime objects, which atlas cannot store. Set it
|
|
123
|
+
false to keep the raw numbers, with their ``units`` and
|
|
124
|
+
``calendar`` attributes.
|
|
125
|
+
convert_calendar: Turn a cftime axis into exact Gregorian timestamps.
|
|
126
|
+
Each one keeps its instant, so a Julian ``1973-02-25`` becomes the
|
|
127
|
+
Gregorian ``1973-03-10`` that names the same moment. A calendar
|
|
128
|
+
with no real instant, such as ``360_day``, and a date outside the
|
|
129
|
+
nanosecond range both raise instead.
|
|
130
|
+
workers: Commit this many datasets at once. The commit is the costly
|
|
131
|
+
half of an ingest. It is pure Rust and releases the GIL, so it
|
|
132
|
+
scales to about four times on a many-core machine. Every netCDF
|
|
133
|
+
read stays on the calling thread, because HDF5 is not thread safe.
|
|
134
|
+
Ordinals do not move, because ``add_dataset`` runs in file order
|
|
135
|
+
whatever the workers do.
|
|
117
136
|
on_error: ``"stop"`` is the default. It abandons the whole collection
|
|
118
137
|
on the first bad file. ``"skip"`` records that file and continues.
|
|
119
138
|
on_unsupported: What one array of an unsupported dtype costs.
|