carray-netcdf 1.0.13 → 2.0.0

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.
@@ -0,0 +1,418 @@
1
+ # Writing files
2
+
3
+ Writing a file is a schema followed by data, and the gem keeps the two phases in the order NetCDF wants:
4
+
5
+ ```
6
+ NC::File.create(path) do |nc|
7
+ nc.def_dim("time", :unlimited) # the schema: dimensions,
8
+ nc.def_var("temp", :float, [...]) # variables,
9
+ nc.def_att("Conventions", "CF-1.7") # and attributes
10
+
11
+ nc["temp"] = ca # then the data
12
+ end
13
+ ```
14
+
15
+ `create` opens the file in define mode, so the definitions come first. You do not have to leave define mode yourself: the first data write does it, and a later definition puts the file back. The block closes the file when it ends.
16
+
17
+ What you assign is a CArray, and the codec encodes it on the way in — the reverse of the read path, so a CArray of physical values can be stored packed, and a masked entry becomes the variable's `_FillValue`.
18
+
19
+ ## Creating
20
+
21
+ ```ruby
22
+ require "carray-netcdf"
23
+ NC = CA::NetCDF
24
+
25
+ # Classic NetCDF-3
26
+ NC::File.create("out.nc") do |nc|
27
+ # ... define dims/vars/atts, then write data
28
+ end
29
+
30
+ # NetCDF-4 (needed for groups, compression, unlimited multi-D, etc.)
31
+ NC::File.create("out.nc", netcdf4: true) do |nc|
32
+ # ...
33
+ end
34
+
35
+ # Refuse to overwrite an existing file
36
+ NC::File.create("out.nc", clobber: false)
37
+
38
+ # Atomic write: publish only on success. Bytes go to a same-directory
39
+ # tmp file; a successful close renames tmp -> "out.nc" (POSIX-atomic).
40
+ # Any exception from the block unlinks the tmp; the target file is
41
+ # untouched (an earlier version, if any, survives intact).
42
+ NC::File.create("out.nc", atomic: true) do |nc|
43
+ # ... define + write. On raise: tmp cleaned up, "out.nc" NOT created /
44
+ # overwritten. On success: "out.nc" appears atomically at end of block.
45
+ end
46
+ ```
47
+
48
+ **Atomic write caveat.** Consumers polling the target path won't see anything until close — for a long-running unlimited-dim streaming write where a downstream reader wants incremental visibility, prefer `atomic: false` (the default).
49
+
50
+ `create` opens the file in **define mode**. The block is the natural place to declare the schema; `File` will drop out of define mode automatically before the first data write, and back into it for further definitions.
51
+
52
+ ## Defining dimensions
53
+
54
+ ```ruby
55
+ nc.def_dim("time", :unlimited) # unlimited (grows as you write)
56
+ nc.def_dim("lat", 73) # fixed length
57
+ nc.def_dim("lon", 144)
58
+ ```
59
+
60
+ `:unlimited` (or the string `"unlimited"`) creates an unlimited dimension. Every other value is coerced with `Integer(...)`. `def_dim` returns the `Dim` and stores it under `nc.dims[name]`.
61
+
62
+ ## Defining variables
63
+
64
+ ```ruby
65
+ nc.def_var("temp", :float, ["time", "lat", "lon"],
66
+ units: "K",
67
+ long_name: "air temperature",
68
+ _FillValue: -999.9,
69
+ valid_range: [200.0, 350.0])
70
+ ```
71
+
72
+ - 1st arg: variable name (String).
73
+ - 2nd arg: type — a Symbol from `:byte :short :int :int64 :float :double :ubyte :ushort :uint :uint64 :char` (plus the CArray-style aliases `:int8 :uint8 :int16 :uint16 :int32 :uint32 :float32 :float64`).
74
+ - 3rd arg: list of dimension names (Strings). An empty list makes a scalar.
75
+ - Keyword args: attributes to attach at define time. Any Ruby-side Numeric, String, or Array is fine.
76
+
77
+ **Type-matched attributes.** `_FillValue`, `missing_value`, `valid_range`, `valid_min`, and `valid_max` must be written in the variable's on-disk type (CF §2.5.1 and libnetcdf enforce this). The gem handles that for you: it detects those attribute names and writes them via typed `nc_put_att_*`, so `_FillValue: -999.9` on a `:float` var becomes `nc_put_att_float`, not the default `nc_put_att_double`. Other attributes go through the generic `nc_put_att` (which infers a reasonable NetCDF type from the Ruby value).
78
+
79
+ **NetCDF-4 storage / filter options.** `def_var` recognizes these keyword arguments alongside the attributes (only effective on a NetCDF-4 file):
80
+
81
+ | Kwarg | Meaning |
82
+ |---|---|
83
+ | `chunks: [1, 73, 144]` | chunk sizes (`NC_CHUNKED`) |
84
+ | `chunks: :contiguous` | force `NC_CONTIGUOUS` |
85
+ | `chunks: :compact` | `NC_COMPACT` (small variables only) |
86
+ | `deflate: 4` | zlib compression level 1..9 (0 / omitted / `nil` = off) |
87
+ | `shuffle: true` | shuffle filter (usually paired with deflate) |
88
+ | `fletcher32: true` | per-chunk fletcher32 checksum |
89
+ | `endian: :little / :big / :native` | on-disk byte order |
90
+ | `no_fill: true` | disable pre-fill (perf: skip fill on unwritten cells) |
91
+
92
+ ```ruby
93
+ NC::File.create("compressed.nc", netcdf4: true) do |nc|
94
+ nc.def_dim("time", :unlimited); nc.def_dim("lat", 73); nc.def_dim("lon", 144)
95
+ nc.def_var("temp", :float, ["time", "lat", "lon"],
96
+ units: "K",
97
+ chunks: [1, 73, 144],
98
+ deflate: 4,
99
+ shuffle: true)
100
+ end
101
+ ```
102
+
103
+ These get passed through to `nc_def_var_chunking` / `nc_def_var_deflate` / `nc_def_var_fletcher32` / `nc_def_var_endian` / `nc_def_var_fill` and take effect immediately. On a NetCDF-3 file, any of them causes libnetcdf to raise; that raise propagates unchanged.
104
+
105
+ **File-wide defaults.** The same kwargs on `File.create` set defaults for every subsequent `def_var` in that file — write once, no need to repeat per variable:
106
+
107
+ ```ruby
108
+ NC::File.create("compressed.nc", netcdf4: true,
109
+ deflate: 4, shuffle: true) do |nc|
110
+ nc.def_dim("time", :unlimited); nc.def_dim("lat", 73); nc.def_dim("lon", 144)
111
+ nc.def_var("temp", :float, ["time", "lat", "lon"]) # inherits deflate=4, shuffle=true
112
+ nc.def_var("wind", :float, ["time", "lat", "lon"]) # same
113
+ nc.def_var("mask", :byte, ["lat", "lon"], deflate: 0) # per-var wins: no compression
114
+ end
115
+ ```
116
+
117
+ Per-var kwargs on `def_var` always override the file-wide default (including `deflate: 0` / `shuffle: false` to explicitly turn off). Note that "file-wide compression" is a convention: libnetcdf has no file-level compression attribute — internally we just call `nc_def_var_deflate` on each new variable with the file's default. If you `def_var` in a second session (after re-open), the earlier file default is *not* remembered and applies only within the create block.
118
+
119
+ Add attributes after the fact with `Var#def_att`:
120
+
121
+ ```ruby
122
+ nc.def_var("temp", :float, ["time", "lat", "lon"], units: "K")
123
+ nc["temp"].def_att("long_name", "air temperature")
124
+ ```
125
+
126
+ ## Inheriting from an existing file (`template:`)
127
+
128
+ The most common workflow — "read one NetCDF, transform, write another with the same skeleton" — is a single kwarg away. Every `def_var` accepts a `template:` argument that supplies its definition (type, dims, attributes) from an existing `Var` (or from a `File` / `Group` by same-name lookup):
129
+
130
+ ```ruby
131
+ NC::File.open("in.nc") do |src|
132
+ NC::File.create("out.nc") do |dst|
133
+ dst.def_dim("lat", 3)
134
+ dst.def_var("lat", template: src["lat"]) # copy definition
135
+ dst.def_var("lat", template: src) # look up "lat" in src
136
+ dst.def_var("latitude", template: src["lat"]) # rename on copy
137
+ dst.def_var("lat", template: src["lat"], # override attributes
138
+ units: "arc_degrees_north")
139
+ dst.def_var("lat", :double, template: src["lat"]) # override type
140
+ end
141
+ end
142
+ ```
143
+
144
+ Positional args (`type` / `dims`) and kwargs override what the template provides. `dims:` and `type:` are also accepted as keyword arguments when mixing with `template:` reads better than positional:
145
+
146
+ ```ruby
147
+ dst.def_var("lat", template: src["lat"], dims: ["latitude"])
148
+ ```
149
+
150
+ `copy_var` combines the definition copy above with a bit-identical raw data copy — perfect for coordinate variables that pass through unchanged:
151
+
152
+ ```ruby
153
+ dst.copy_var(src["lat"]) # same name, values included
154
+ dst.copy_var(src["lat"], name: "latitude") # rename
155
+ dst.copy_var("lat", from: src) # look up by name
156
+ %w[time lat lon].each { |n| dst.copy_var(n, from: src) }
157
+ ```
158
+
159
+ The data goes through the source's `raw` (no decode) and layer 1 (no encode), so the on-disk bytes match exactly — a codec-mediated variable (scale/offset, `_FillValue`) round-trips without any floating-point drift. If you want a codec-mediated copy, drop back to `def_var(template:)` and write `dst[name] = src[name].to_ca` yourself.
160
+
161
+ `copy_dim` copies a dimension (including the unlimited flag) but not its coordinate variable:
162
+
163
+ ```ruby
164
+ dst.copy_dim(src.dim("time")) # unlimited stays unlimited
165
+ dst.copy_dim("lat", from: src)
166
+ ```
167
+
168
+ **File-level template.** `File.create` accepts `template:` for whole-file skeleton inheritance — dims, global attributes, and (if given in `vars:`) coordinate variables are copied before your block runs:
169
+
170
+ ```ruby
171
+ NC::File.open("in.nc") do |src|
172
+ NC::File.create("out.nc",
173
+ template: src,
174
+ vars: %w[time lat lon]) do |dst|
175
+ # dims, global atts, and coord vars are already there
176
+ dst.def_var("result", :float, ["time","lat","lon"], units: "1")
177
+ dst["result"] = compute(src["temp"].to_ca)
178
+ end
179
+ end
180
+ ```
181
+
182
+ `template:` also works with `dims:`, `atts:`, and format inheritance:
183
+
184
+ | Kwarg | Meaning |
185
+ |---|---|
186
+ | `template: src` | inherit dims, atts, and format (netcdf-4 upgrades) |
187
+ | `dims: true` (default) | copy all template dims |
188
+ | `dims: false` | copy no dims |
189
+ | `dims: %w[time lat]` | copy just these dims |
190
+ | `atts: true` (default) | copy all global atts |
191
+ | `atts: false` | copy no atts |
192
+ | `atts: { "history" => "..." }` | copy all, then apply Hash overrides |
193
+ | `vars: %w[time lat lon]` | after dims/atts, `copy_var` each of these |
194
+
195
+ Explicit `netcdf4:` always wins over template inheritance.
196
+
197
+ ## Idempotent batch creation (`clobber: :skip`)
198
+
199
+ For pipelines that generate one output file per input chunk, `clobber:` also accepts `:skip` — if the target already exists, the block is not called and `File.create` returns `nil`:
200
+
201
+ ```ruby
202
+ NC::File.open("reference.nc") do |ref|
203
+ time_windows.each do |window|
204
+ NC::File.create("out_#{window.tag}.nc",
205
+ template: ref,
206
+ vars: %w[lat lon],
207
+ clobber: :skip,
208
+ atomic: true) do |dst|
209
+ # only runs for windows not already produced
210
+ dst.def_var("time", :int, ["time"],
211
+ units: "hours since #{window.origin}", calendar: "standard")
212
+ dst.def_var("temp", :float, ["time","lat","lon"], _FillValue: -999.9)
213
+ window.slabs { |t, slab| dst["temp", t, nil, nil] = slab }
214
+ end
215
+ end
216
+ end
217
+ ```
218
+
219
+ With `atomic: true` on top, a mid-loop crash leaves neither a partial file nor a stale one — `Dir["out_*.nc"]` tells the truth about what completed. See the [cookbook batch chapter](07_cookbook.md) for a full example.
220
+
221
+ ## Defining global attributes
222
+
223
+ ```ruby
224
+ nc.def_att("Conventions", "CF-1.7")
225
+ nc.def_att("title", "Reanalysis subset")
226
+ nc.def_att("history", "Created #{Time.now}")
227
+ ```
228
+
229
+ `def_att` on the File writes to `NC_GLOBAL`. On a `Group` it writes to that group's global-attribute set (see [Groups](05_groups.md)).
230
+
231
+ ## Writing data
232
+
233
+ ### Whole-variable write
234
+
235
+ ```ruby
236
+ nc["temp"] = CArray.float32(1, 73, 144) { |i, j, k| some_data(i, j, k) }
237
+ ```
238
+
239
+ The CArray shape must match the variable's shape. Non-contiguous CArrays (views) are automatically materialized to a contiguous buffer before the write.
240
+
241
+ ### Hyperslab write
242
+
243
+ ```ruby
244
+ # Write one time step
245
+ nc["temp", 0, nil, nil] = one_slice # one_slice.shape == [73, 144]
246
+
247
+ # Write ten time steps starting at t=5
248
+ nc["temp", 5..14, nil, nil] = ten_days # ten_days.shape == [10, 73, 144]
249
+
250
+ # Write a single point
251
+ nc["temp", 0, 0, 0] = 273.15
252
+ ```
253
+
254
+ Under the hood the index dispatches to `nc_put_var1` (single point), `nc_put_vara` (contiguous slab), or `nc_put_vars` (strided slab).
255
+
256
+ ### Unlimited-dimension streaming
257
+
258
+ An unlimited dimension grows to fit whatever you write:
259
+
260
+ ```ruby
261
+ NC::File.create("stream.nc") do |nc|
262
+ nc.def_dim("time", :unlimited)
263
+ nc.def_dim("station", 10)
264
+ nc.def_var("obs", :double, ["time", "station"])
265
+
266
+ # Write day-by-day. netcdf grows the time dimension automatically.
267
+ 100.times do |day|
268
+ nc["obs", day, nil] = daily_observations(day) # shape [10]
269
+ end
270
+ end
271
+ ```
272
+
273
+ You don't need to declare the eventual size — writing beyond the current length grows the unlimited dimension. Every fixed-size dimension must match the write.
274
+
275
+ ### Writing with a mask
276
+
277
+ Layer 2 handles masks for you via the codec. On a variable with a `_FillValue` attribute, `UNDEF` cells are encoded to the fill sentinel on write; on read, fill cells decode back to `UNDEF` (see [Codecs](04_codec.md)):
278
+
279
+ ```ruby
280
+ NC::File.create("masked.nc") do |nc|
281
+ nc.def_dim("n", 5)
282
+ nc.def_var("v", :float, ["n"], _FillValue: -999.9)
283
+ nc.def_att("Conventions", "CF-1.7")
284
+
285
+ ca = CA_FLOAT([1.0, 2.0, 3.0, 4.0, 5.0])
286
+ ca[2] = UNDEF
287
+ nc["v"] = ca # cell [2] is written as -999.9 on disk
288
+ end
289
+ ```
290
+
291
+ Writing a masked CArray to a variable without a `_FillValue` attribute raises — the gem refuses to invent a sentinel silently.
292
+
293
+ ## Type-matched writes and libnetcdf coercion
294
+
295
+ You can hand libnetcdf any numeric type; it converts on the way out to match the on-disk type:
296
+
297
+ ```ruby
298
+ nc.def_var("count", :int, ["time"])
299
+ nc["count"] = CArray.int64(...) # OK, libnetcdf narrows int64 -> int32
300
+
301
+ nc.def_var("temp", :float, ["time"])
302
+ nc["temp"] = CArray.float64(...) # OK, libnetcdf narrows double -> float
303
+ ```
304
+
305
+ If a value would overflow the target type, libnetcdf raises. When you're writing physical values into a **packed** (scale/offset) variable, the codec divides by `scale_factor`, subtracts `add_offset`, and rounds to the target integer type before the write — see the [Codecs chapter](04_codec.md).
306
+
307
+ ## Modifying an existing file
308
+
309
+ ```ruby
310
+ NC::File.open("data.nc", :write) do |nc|
311
+ nc["temp", 0, nil, nil] = new_slice # overwrite a slab
312
+ nc.def_att("history", "updated #{Time.now}") # new global attribute
313
+ nc["temp"].def_att("comment", "revised on ingest")
314
+ end
315
+ ```
316
+
317
+ `File.open(:write)` opens for modification. Adding a new dimension or variable is possible too — `File` re-enters define mode as needed.
318
+
319
+ Adding a new variable to an existing NetCDF-3 file rewrites the file layout (a libnetcdf constraint, not ours). NetCDF-4 files (`netcdf4: true`) are more flexible.
320
+
321
+ ## Zarr stores
322
+
323
+ libnetcdf 4.8 and later can read and write Zarr. libnetcdf calls this support NCZarr. A Zarr store is a directory, not one file: each chunk of a variable is a file of its own, and the metadata is in small JSON files.
324
+
325
+ The gem has no separate API for it. Pass a URL where a path would go, and give the format in its `#mode=` part:
326
+
327
+ ```ruby
328
+ url = "file:///data/t.zarr#mode=zarr,file"
329
+
330
+ NC::File.create(url, netcdf4: true) do |nc|
331
+ nc.def_dim("y", 2)
332
+ nc.def_dim("x", 3)
333
+ nc.def_var("t", :short, %w[y x], scale_factor: 0.5, _FillValue: -1)
334
+ nc.def_att("Conventions", "CF-1.8")
335
+ nc["t"] = ca
336
+ end
337
+
338
+ NC::File.open(url) { |nc| nc["t"][] } # decoded as from a .nc file
339
+ NC::File.open(url, :write) { |nc| nc["t", 0, nil] = row }
340
+ ```
341
+
342
+ - `mode=zarr` reads and writes plain Zarr, the form other tools such as xarray use. `mode=nczarr` also writes netCDF's own metadata, such as shared dimensions, into the store.
343
+ - `file` puts the store in a local directory. `s3` puts it in Amazon S3, when libnetcdf was built with S3 support.
344
+ - The `#mode=` part is required. A plain path ending in `.zarr` creates an ordinary HDF5 file.
345
+
346
+ Dimensions, attributes, groups, chunking, compression, the codec and hyperslab reads and writes all work as for a NetCDF-4 file. User-defined types do not.
347
+
348
+ `atomic:` and `clobber: false` / `clobber: :skip` need a file path, so `File.create` raises `ArgumentError` when they come with a URL. To keep an existing store, check for its directory yourself before calling `create`.
349
+
350
+ This needs a libnetcdf built with NCZarr. `nc-config --has-nczarr` says whether yours was.
351
+
352
+ ## A complete CF-compliant example
353
+
354
+ ```ruby
355
+ require "carray-netcdf"
356
+ NC = CA::NetCDF
357
+
358
+ NC::File.create("cf_demo.nc") do |nc|
359
+ # Dimensions
360
+ nc.def_dim("time", :unlimited)
361
+ nc.def_dim("lat", 73)
362
+ nc.def_dim("lon", 144)
363
+
364
+ # Coordinate variables
365
+ nc.def_var("time", :int, ["time"],
366
+ standard_name: "time",
367
+ long_name: "time",
368
+ units: "hours since 2022-01-01T00:00:00Z",
369
+ calendar: "standard")
370
+ nc.def_var("lat", :float, ["lat"],
371
+ standard_name: "latitude",
372
+ long_name: "latitude",
373
+ units: "degrees_north")
374
+ nc.def_var("lon", :float, ["lon"],
375
+ standard_name: "longitude",
376
+ long_name: "longitude",
377
+ units: "degrees_east")
378
+
379
+ # Data variable
380
+ nc.def_var("temp", :float, ["time", "lat", "lon"],
381
+ standard_name: "air_temperature",
382
+ long_name: "surface air temperature",
383
+ units: "K",
384
+ _FillValue: -999.9,
385
+ valid_range: [200.0, 350.0])
386
+
387
+ # Global attributes
388
+ nc.def_att("Conventions", "CF-1.7")
389
+ nc.def_att("title", "Synthetic CF demo")
390
+ nc.def_att("history", "Created #{Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}")
391
+
392
+ # Coordinate data
393
+ nc["lat"] = CArray.float32(73) { |i| -90.0 + i * 2.5 }
394
+ nc["lon"] = CArray.float32(144) { |i| i * 2.5 }
395
+ nc["time"] = CArray.time_series("2022-01-01T00:00:00Z", count: 24, unit: :h)
396
+
397
+ # Data — 24 hourly slices
398
+ 24.times do |t|
399
+ nc["temp", t, nil, nil] = CArray.float32(73, 144) { |i, j|
400
+ 273.15 + 20.0 * Math.sin(i * 0.1 + t * 0.2) + Math.cos(j * 0.05)
401
+ }
402
+ end
403
+ end
404
+ ```
405
+
406
+ Round-tripping this file:
407
+
408
+ ```ruby
409
+ NC::File.open("cf_demo.nc") do |nc|
410
+ nc.att("Conventions") # => "CF-1.7"
411
+
412
+ t = nc["time"].to_ca # => CATime, hour resolution
413
+ t[0] # => #<CATime::Element 2022-01-01T00:00:00Z>
414
+
415
+ temp = nc["temp"].to_ca # decoded; fill sentinel -> mask
416
+ temp.mean # centered around 273-290 K
417
+ end
418
+ ```