carray-netcdf 2.0.0 → 2.1.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.
data/docs/07_cookbook.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cookbook
2
2
 
3
- End-to-end scenarios. Each example is self-contained; copy, adapt, run.
3
+ End-to-end scenarios, from writing a file through reading, copying and converting one to working with data larger than memory. Some recipes read a file an earlier one wrote, and say which; names such as `time_windows` or `process` stand for your own data and code.
4
4
 
5
5
  ## 1. Write a CF-1.7 hourly temperature file
6
6
 
@@ -61,21 +61,14 @@ NC::File.open("hourly_temp.nc") do |nc|
61
61
 
62
62
  temp = nc["temp"].to_ca # decoded (fill -> mask if present)
63
63
  temp.shape # => [24, 73, 144]
64
- temp.mean # → around 273-285 K
65
- end
66
- ```
67
-
68
- ## 2. Read the third time step of a CF file, mask-aware
64
+ temp.mean # => about 271.8
69
65
 
70
- ```ruby
71
- NC::File.open("hourly_temp.nc") do |nc|
72
- slab = nc["temp"][2, nil, nil] # rank-2 CArray, shape [73, 144]
73
- slab.count_masked # 0 unless the file has fill cells here
74
- slab_at_equator = slab[36, nil] # rank-1 CArray, shape [144]
66
+ slab = nc["temp"][2, nil, nil] # the third time step only, shape [73, 144]
67
+ slab.count_masked # 0 unless the file has fill cells there
75
68
  end
76
69
  ```
77
70
 
78
- ## 3. Pack a physical field into a short (satellite-style storage)
71
+ ## 2. Pack a physical field into a short
79
72
 
80
73
  Storing a physical range that fits comfortably in `short` saves half the space of `float`. CF's `scale_factor` / `add_offset` handle it:
81
74
 
@@ -117,70 +110,11 @@ NC::File.open("packed.nc", codec: [:cf, mask_valid_range: true]) do |nc|
117
110
  end
118
111
  ```
119
112
 
120
- ## 4. Stream a large variable one slab at a time
121
-
122
- ```ruby
123
- require "carray-netcdf"
124
- NC = CA::NetCDF
113
+ ## 3. Append time steps to an unlimited-dimension file
125
114
 
126
- # Assume /big.nc has variable temp[time, lat, lon] with time = 10000.
127
- # Compute the daily mean without ever holding the whole array.
128
-
129
- means = CArray.float64(10000)
130
-
131
- NC::File.open("/big.nc") do |nc|
132
- temp = nc["temp"]
133
- n_time = temp.shape[0]
134
-
135
- # For zero-allocation streaming, drop to layer 1. Layer 2's hyperslab
136
- # read allocates a fresh CArray per step; layer 1 lets us reuse a buffer.
137
- vid = ::NetCDF.nc_inq_varid(nc.ncid, "temp")
138
- buf = CArray.float32(1, *temp.shape[1..]) # [1, lat, lon]
139
-
140
- n_time.times do |k|
141
- NC.nc_get_vara(nc.ncid, vid, [k, 0, 0], [1, *temp.shape[1..]], buf)
142
- means[k] = buf.mean # raw physical bytes
143
- end
144
- end
145
- ```
146
-
147
- If your file has `_FillValue` or `scale_factor`, layer 1 doesn't apply them — you'd need to either drop that particular streaming loop into layer 2 (which allocates per slab but is one line: `means[k] = nc["temp"][k, nil, nil].mean`), or apply the transforms yourself.
148
-
149
- ## 4b. Generate a batch of files from a single reference
150
-
151
- The classic "read one, write many" pipeline: hold a reference NetCDF open, loop over some units of work, and emit one output per iteration that inherits the reference's skeleton (dims, global atts, coordinate vars) and only diverges in the payload. `File.create(template:, vars:)` collapses the boilerplate; `clobber: :skip` + `atomic: true` make the loop restartable.
115
+ Adds one hour to the file from recipe 1:
152
116
 
153
117
  ```ruby
154
- NC::File.open("reference.nc") do |ref|
155
- time_windows.each do |window| # e.g. one 24h chunk per iteration
156
- NC::File.create("out_#{window.tag}.nc",
157
- template: ref, # dims + atts + format inherited
158
- vars: %w[lat lon], # coord vars: full bit-identical copy
159
- clobber: :skip, # already-produced files are left alone
160
- atomic: true) do |dst|
161
- dst.def_var("time", :int, ["time"],
162
- units: "hours since #{window.origin}", calendar: "standard")
163
- dst.def_var("temp", :float, ["time","lat","lon"], _FillValue: -999.9)
164
- dst["time"] = window.timestamps
165
- window.each_slab { |t, slab| dst["temp", t, nil, nil] = slab }
166
- end
167
- end
168
- end
169
- ```
170
-
171
- Why the combination matters:
172
-
173
- - **`template: ref`** — dims, `Conventions`, `title`, and any custom global attributes carry over. Output inherits the NetCDF-4 format if the reference is netcdf-4 (so groups / deflate work), unless `netcdf4:` is set explicitly.
174
- - **`vars: %w[lat lon]`** — coordinate variables that don't change between outputs come along for the ride, byte-identical (no codec on either side). Downstream diff / dedup tools won't see spurious floating-point drift.
175
- - **`clobber: :skip`** — reruns pick up where a previous run stopped; finished files are not touched. Combines cleanly with `atomic:`.
176
- - **`atomic: true`** — a mid-loop crash leaves neither a half-written output nor a stale one. `Dir["out_*.nc"]` reflects exactly what completed successfully.
177
-
178
- For read-side workflows that consume the output, the split is clean: either the file is fully there (produced by a completed `create` block), or it is not there yet. No corrupt-file recovery logic in downstream code.
179
-
180
- ## 5. Append time steps to an unlimited-dimension file
181
-
182
- ```ruby
183
- # Existing file created with time = :unlimited.
184
118
  NC::File.open("hourly_temp.nc", :write) do |nc|
185
119
  cur = nc.dim("time").length # e.g. 24
186
120
 
@@ -191,7 +125,7 @@ NC::File.open("hourly_temp.nc", :write) do |nc|
191
125
  end
192
126
  ```
193
127
 
194
- ## 6. Group your model by subsystem (NetCDF-4)
128
+ ## 4. Group your model by subsystem (NetCDF-4)
195
129
 
196
130
  ```ruby
197
131
  NC::File.create("model.nc", netcdf4: true) do |nc|
@@ -213,10 +147,10 @@ NC::File.create("model.nc", netcdf4: true) do |nc|
213
147
  units: "hours since 2024-01-01T00:00:00Z", calendar: "standard")
214
148
 
215
149
  # Write some data
216
- T = 6
217
- nc["time"] = CArray.time_series("2024-01-01T00:00:00Z", count: T, unit: :h)
150
+ steps = 6
151
+ nc["time"] = CArray.time_series("2024-01-01T00:00:00Z", count: steps, unit: :h)
218
152
 
219
- T.times do |t|
153
+ steps.times do |t|
220
154
  ocean["temperature", t, nil] = CArray.float32(50) { |z| 25.0 - z * 0.4 }
221
155
  ocean["salinity", t, nil] = CArray.float32(50) { |z| 35.0 + z * 0.01 }
222
156
  atm["pressure", t, nil] = CArray.float32(30) { |z| 1013.0 * Math.exp(-z / 8.0) }
@@ -236,13 +170,33 @@ NC::File.open("model.nc") do |nc|
236
170
  end
237
171
  ```
238
172
 
239
- ## 7. Inspect a mystery file quickly
173
+ ## 5. Select a time range by date
174
+
175
+ Find the steps between two instants on the time coordinate, then read only those. With the file from recipe 1:
176
+
177
+ ```ruby
178
+ NC::File.open("hourly_temp.nc") do |nc|
179
+ t = nc["time"].to_ca # CATime
180
+ from = CArray.time(["2022-01-01T06:00:00Z"], unit: :h)[0]
181
+ upto = CArray.time(["2022-01-01T11:00:00Z"], unit: :h)[0]
182
+
183
+ k = ((t >= from) & (t <= upto)).where # => [6, 7, 8, 9, 10, 11]
184
+ morning = nc["temp"][k.min..k.max, nil, nil] # reads those 6 steps only
185
+ morning.shape # => [6, 73, 144]
186
+ end
187
+ ```
188
+
189
+ The comparison works on instants, so `from` and `upto` may be at any resolution, a day as well as an hour. Reading the range `k.min..k.max` rather than `k` itself keeps the read a single block, which matters when the steps are many.
190
+
191
+ ## 6. Inspect a mystery file quickly
240
192
 
241
193
  ```ruby
242
194
  NC::File.open("mystery.nc") do |nc|
243
- puts "format: #{nc.format}"
195
+ format = NetCDF.constants.grep(/\ANC_FORMAT_/).find { |c| NetCDF.const_get(c) == nc.format }
196
+ codec = nc.codec.is_a?(Module) ? nc.codec : nc.codec.class # Identity is a module
197
+ puts "format: #{format}"
244
198
  puts "conventions: #{nc.att('Conventions') || '(none)'}"
245
- puts "codec: #{nc.codec.class.name.split('::').last}"
199
+ puts "codec: #{codec.name.split('::').last}"
246
200
  puts
247
201
 
248
202
  puts "dimensions:"
@@ -267,27 +221,141 @@ NC::File.open("mystery.nc") do |nc|
267
221
  end
268
222
  ```
269
223
 
270
- ## 8. Round-trip decoded vs raw for a single variable
224
+ For the whole header as CDL, JSON or YAML, the `ncheader` command does this from the shell (see [Command-line tools](09_cli.md)). To see what the codec does to one variable, compare `v.to_ca` with `v.raw` (see [Seeing what the codec changed](04_codec.md#seeing-what-the-codec-changed)).
225
+
226
+ ## 7. Cut a region out into a new file
227
+
228
+ Copy a latitude band of the file from recipe 1, with each variable's definition and attributes, through the codec: values are decoded on the way out and encoded again on the way in, so masks and packing carry over.
229
+
230
+ ```ruby
231
+ NC::File.open("hourly_temp.nc") do |src|
232
+ lat = src["lat"].to_ca
233
+ j = ((lat >= 20) & (lat <= 50)).where # the rows of the band
234
+ rows = j.min..j.max
235
+
236
+ NC::File.create("band.nc", template: src, dims: %w[time lon], vars: %w[time lon]) do |dst|
237
+ dst.def_dim("lat", rows.size)
238
+ dst.def_var("lat", template: src["lat"])
239
+ dst.def_var("temp", template: src["temp"])
240
+ dst["lat"] = src["lat"][rows]
241
+ src["time"].shape[0].times do |t| # one time step at a time
242
+ dst["temp", t, nil, nil] = src["temp"][t, rows, nil]
243
+ end
244
+ end
245
+ end
246
+ ```
247
+
248
+ `File.create(template:)` brings the global attributes and, with `dims:` / `vars:`, the dimensions and coordinate variables that stay the same; `def_var(template:)` takes each changed variable's type and attributes. Writing a step at a time keeps one slab in memory.
249
+
250
+ ## 8. Convert a NetCDF-3 file to compressed NetCDF-4
251
+
252
+ ```ruby
253
+ NC::File.open("hourly_temp.nc") do |src|
254
+ NC::File.create("hourly_temp4.nc", template: src, format: :netcdf4) do |dst|
255
+ src.vars.each_key do |name|
256
+ dst.copy_var(name, from: src, deflate: 4, shuffle: true)
257
+ end
258
+ end
259
+ end
260
+
261
+ NC::File.open("hourly_temp4.nc") { |nc| nc["temp"].storage_options }
262
+ # => {chunks: [1, 73, 144], deflate: 4, shuffle: true}
263
+ ```
264
+
265
+ `template:` brings the dimensions and global attributes, and `copy_var` each variable with its stored values unchanged; the kwargs set the compression. On this file that halves its size. Add `chunks:` to choose the chunking yourself, or use `zstandard:` (libnetcdf 4.9 and later) for faster compression; see [Writing files](03_writing.md).
271
266
 
272
- Handy when a file behaves unexpectedly and you want to see what the codec is doing:
267
+ ## 9. Generate a batch of files from a single reference
268
+
269
+ Hold a reference file open, and write one output per unit of work, each with the reference's dimensions, global attributes and coordinate variables. `File.create(template:, vars:)` copies those; `clobber: :skip` and `atomic: true` let the loop be run again after a failure. Here the reference's `time` dimension is unlimited, so each output can hold its own number of steps, and each `window` gives `tag`, `origin`, `timestamps` (a CATime) and `each_slab`, which yields a time index and a `[lat, lon]` CArray.
273
270
 
274
271
  ```ruby
275
- NC::File.open("data.nc") do |nc|
276
- decoded = nc["temp"].to_ca
277
- puts "decoded: mean = #{decoded.mean}, masked = #{decoded.count_masked}"
272
+ NC::File.open("reference.nc") do |ref|
273
+ time_windows.each do |window| # e.g. one 24h chunk per iteration
274
+ NC::File.create("out_#{window.tag}.nc",
275
+ template: ref, # dims + atts + format inherited
276
+ vars: %w[lat lon], # coord vars: full bit-identical copy
277
+ clobber: :skip, # already-produced files are left alone
278
+ atomic: true) do |dst|
279
+ dst.def_var("time", :int, ["time"],
280
+ units: "hours since #{window.origin}", calendar: "standard")
281
+ dst.def_var("temp", :float, ["time","lat","lon"], _FillValue: -999.9)
282
+ dst["time"] = window.timestamps
283
+ window.each_slab { |t, slab| dst["temp", t, nil, nil] = slab }
284
+ end
285
+ end
278
286
  end
287
+ ```
288
+
289
+ Why the combination matters:
279
290
 
280
- NC::File.open("data.nc", codec: :identity) do |nc|
281
- raw = nc["temp"].to_ca
282
- puts "raw: mean = #{raw.mean}, min = #{raw.min}, max = #{raw.max}"
283
- puts "attrs: #{nc['temp'].atts.inspect}"
291
+ - **`template: ref`** — dims, `Conventions`, `title`, and any custom global attributes carry over. The output has the reference's format, unless `format:` or `netcdf4:` is given.
292
+ - **`vars: %w[lat lon]`** — coordinate variables that are the same in every output are copied with their stored values unchanged, so they compare equal byte for byte.
293
+ - **`clobber: :skip`** — a rerun skips the outputs that already exist and writes the rest.
294
+ - **`atomic: true`** — an exception mid-loop leaves no half-written output, so `Dir["out_*.nc"]` lists only completed files. A killed process can leave a `*.tmp` file behind.
295
+
296
+ ## 10. Stream a large variable one slab at a time
297
+
298
+ ```ruby
299
+ require "carray-netcdf"
300
+ NC = CA::NetCDF
301
+
302
+ # big.nc has a variable temp[time, lat, lon] with many time steps.
303
+ # Compute the mean of each time step without holding the whole array.
304
+
305
+ NC::File.open("big.nc") do |nc|
306
+ temp = nc["temp"]
307
+ n_time = temp.shape[0]
308
+ means = CArray.float64(n_time)
309
+
310
+ # The low-level API reads each step into the same buffer, so nothing is
311
+ # allocated per step.
312
+ buf = CArray.float32(1, *temp.shape[1..]) # [1, lat, lon]
313
+
314
+ n_time.times do |k|
315
+ NC.nc_get_vara(nc.ncid, temp.varid, [k, 0, 0], [1, *temp.shape[1..]], buf)
316
+ means[k] = buf.mean # stored values, not decoded
317
+ end
318
+ end
319
+ ```
320
+
321
+ The low-level API applies no codec, so `buf` holds the stored values. If the variable has a `_FillValue` or `scale_factor`, decode each step with `temp.codec.decode(temp, buf)`, or read it with `temp[k, nil, nil]`, which decodes but allocates a new array per step.
322
+
323
+ ## 11. Read in one thread, compute in another
324
+
325
+ libnetcdf runs without the GVL, so the main thread can compute on one slab while a reader thread reads the next:
326
+
327
+ ```ruby
328
+ require "carray-netcdf"
329
+ NC = CA::NetCDF
330
+
331
+ queue = SizedQueue.new(4) # at most 4 slabs held at once
332
+
333
+ reader = Thread.new do
334
+ NC::File.open("big.nc") do |nc| # this File is used by this thread only
335
+ temp = nc["temp"]
336
+ temp.shape[0].times { |k| queue << temp[k, nil, nil] }
337
+ end
338
+ ensure
339
+ queue << nil # no more slabs, even if the read failed
340
+ end
341
+
342
+ means = []
343
+ while slab = queue.pop # each slab is handed over once it has been read
344
+ means << slab.mean
284
345
  end
346
+ reader.join # re-raises an error from the reader thread
285
347
  ```
286
348
 
287
- ## 9. Write a small file with a bit-exact custom codec
349
+ This stays within what is safe with threads (see [Threads and Ractors](00_introduction.md#threads-and-ractors)):
350
+
351
+ - **One thread owns the file.** The `File` and its `Var` are opened, used and closed in the reader thread. To read several files at once, give each thread its own `File`; the calls into libnetcdf still run one at a time.
352
+ - **A CArray changes hands only after its read.** A slab goes into the queue once it has been read, and the reader does not touch it again.
353
+
354
+ ## 12. Mask fill values without unpacking
355
+
356
+ A codec of your own can apply only part of CF. This one turns `_FillValue` into a mask and back, and leaves `scale_factor` and `add_offset` alone, so the values stay packed:
288
357
 
289
358
  ```ruby
290
- # A minimal codec that only masks _FillValue but skips scale/offset/time.
291
359
  class OnlyFillCodec
292
360
  def decode(var, ca)
293
361
  fill = var.att("_FillValue") or return ca
@@ -304,42 +372,15 @@ class OnlyFillCodec
304
372
  end
305
373
  end
306
374
 
307
- NC::File.open("packed.nc", codec: OnlyFillCodec.new) do |nc|
308
- raw_with_mask = nc["temp"].to_ca # packed short values, fill cells masked,
309
- # no scale/offset applied
375
+ NC::File.create("fill_only.nc", codec: OnlyFillCodec.new) do |nc|
376
+ nc.def_dim("n", 3)
377
+ nc.def_var("v", :short, ["n"], scale_factor: 0.5, _FillValue: -1)
378
+ nc["v"] = CA_SHORT([1, UNDEF, 3]) # the masked element is written as -1
310
379
  end
311
- ```
312
-
313
- The codec object just has to respond to `decode(var, ca)` and `encode(var, ca)`. See the [Codecs chapter](04_codec.md) for the full interface and how to register a codec globally.
314
380
 
315
- ## 10. Existing-file inspection with `ncdump`-like output
316
-
317
- ```ruby
318
- def cdl_summary(nc)
319
- puts "netcdf {"
320
- puts "dimensions:"
321
- nc.dims.each { |n, d|
322
- len = d.unlimited? ? "UNLIMITED" : d.length.to_s
323
- puts " #{n} = #{len};"
324
- }
325
- puts "variables:"
326
- nc.vars.each { |n, v|
327
- dims = v.dims.map(&:name).join(", ")
328
- puts " #{v.data_type} #{n}(#{dims});"
329
- v.atts.each { |k, val|
330
- puts " #{n}:#{k} = #{val.inspect};"
331
- }
332
- }
333
- puts "// global attributes:"
334
- nc.atts.each { |k, v| puts " :#{k} = #{v.inspect};" }
335
- nc.groups.each { |gname, g|
336
- puts
337
- puts "group: #{gname} {"
338
- cdl_summary(g)
339
- puts "} // group #{gname}"
340
- }
341
- puts "}"
381
+ NC::File.open("fill_only.nc", codec: OnlyFillCodec.new) do |nc|
382
+ nc["v"].to_ca.to_a # => [1, UNDEF, 3] (still packed)
342
383
  end
343
-
344
- NC::File.open("mystery.nc") { |nc| cdl_summary(nc) }
345
384
  ```
385
+
386
+ The codec object just has to respond to `decode(var, ca)` and `encode(var, ca)`. See the [Codecs chapter](04_codec.md) for the full interface and how to register a codec, so that files whose `Conventions` it claims pick it up.