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.
data/docs/06_layer1.md ADDED
@@ -0,0 +1,160 @@
1
+ # Layer 1 — raw `nc_*` API
2
+
3
+ Everyday code uses layer 2 (`File`, `Var`, ...). Layer 1 is the CArray-flavored view of the netcdf gem's `nc_*` functions — same names, same argument order, but numeric buffers are CArrays.
4
+
5
+ Use it when:
6
+
7
+ - You want to open by ncid or work outside the `File` object model.
8
+ - You want to stream a very large variable into a preallocated buffer (`nc_get_vara` with an explicit buffer = zero allocation per read).
9
+ - You're bridging to code that already speaks the C `nc_*` idiom.
10
+ - You want to skip the codec and every layer-2 semantics for a specific operation.
11
+
12
+ ## The module
13
+
14
+ `CA::NetCDF` itself IS layer 1. It `include`s `::NetCDF` (the netcdf gem's module) so all `nc_*` functions and `NC_*` constants pass through, and overrides only the array I/O to speak CArray.
15
+
16
+ ```ruby
17
+ require "carray-netcdf"
18
+ NC = CA::NetCDF
19
+ ```
20
+
21
+ Two idioms:
22
+
23
+ ```ruby
24
+ # (a) call style — module functions
25
+ NC.nc_get_vara(ncid, varid, [0, 0], [3, 4]) # => CArray
26
+ NC::NC_DOUBLE # constants inherited from ::NetCDF
27
+
28
+ # (b) mixin style — `nc_*` become bare methods in your class
29
+ class Reader
30
+ include CA::NetCDF
31
+ def read_all(ncid, varid) = nc_get_var(ncid, varid) # bare, returns CArray
32
+ def define(ncid, name) = nc_def_dim(ncid, name, 100) # passes through
33
+ end
34
+ ```
35
+
36
+ ## What layer 1 overrides vs passes through
37
+
38
+ Overridden (numeric array I/O):
39
+
40
+ - `nc_get_var`, `nc_get_vara`, `nc_get_vars`, `nc_get_varm`
41
+ - `nc_put_var`, `nc_put_vara`, `nc_put_vars`, `nc_put_varm`
42
+
43
+ Everything else — `nc_open`, `nc_create`, `nc_def_dim`, `nc_def_var`, `nc_put_att`, `nc_get_att`, `nc_inq_*`, `nc_def_grp`, `nc_close`, `NC_DOUBLE`, `NC_CLOBBER`, ... — comes straight from the netcdf gem, which this layer includes without modifying.
44
+
45
+ ## `nc_get_var*` — buffer-less returns a CArray
46
+
47
+ Without a buffer argument, layer 1 allocates a CArray of the requested shape, typed from the variable's on-disk type (`NC_CHAR` as `:uint8`, an enum as its base type), and reads into it:
48
+
49
+ ```ruby
50
+ ncid = ::NetCDF.nc_open("data.nc", ::NetCDF::NC_NOWRITE)
51
+ vid = ::NetCDF.nc_inq_varid(ncid, "temp")
52
+
53
+ ca = NC.nc_get_var(ncid, vid) # whole variable -> CArray
54
+ ca = NC.nc_get_vara(ncid, vid, [0, 0], [3, 4]) # hyperslab -> CArray
55
+ ca = NC.nc_get_vars(ncid, vid, [0, 0], [3, 4], [1, 2]) # strided -> CArray
56
+ ca = NC.nc_get_varm(ncid, vid, [0, 0], [3, 4], [1, 1], [4, 1]) # mapped -> CArray
57
+
58
+ ::NetCDF.nc_close(ncid)
59
+ ```
60
+
61
+ No codec runs — this is raw, no `_FillValue → UNDEF`, no scale/offset, no CATime. Use layer 2 for those.
62
+
63
+ A type with no CArray counterpart — opaque, `NC_STRING` — raises `NotImplementedError`; read an opaque variable into a buffer of your own.
64
+
65
+ ## `nc_get_var*` — with a buffer, reads into it
66
+
67
+ Pass an existing CArray and layer 1 asks netcdf to read into that buffer. Perfect for streaming a slab at a time with **zero allocation per read**:
68
+
69
+ ```ruby
70
+ ncid = ::NetCDF.nc_open("big.nc", ::NetCDF::NC_NOWRITE)
71
+ vid = ::NetCDF.nc_inq_varid(ncid, "temp")
72
+
73
+ buf = CArray.float32(73, 144) # one time slice
74
+
75
+ 365.times do |day|
76
+ NC.nc_get_vara(ncid, vid, [day, 0, 0], [1, 73, 144], buf)
77
+ process(buf) # buf now holds day `day`
78
+ end
79
+
80
+ ::NetCDF.nc_close(ncid)
81
+ ```
82
+
83
+ The buffer must be C-contiguous (a plain entity CArray is; a view is not — the gem will refuse a non-contiguous buffer). Type conversion is handled by libnetcdf — you can read a short variable into a float64 buffer and libnetcdf converts.
84
+
85
+ ## `nc_put_var*` — CArray as buffer
86
+
87
+ Symmetric. A contiguous CArray goes through as-is:
88
+
89
+ ```ruby
90
+ NC.nc_put_vara(ncid, vid, [0, 0], [3, 4], CArray.float32(3, 4).seq!)
91
+ ```
92
+
93
+ A non-contiguous CArray (a view / slice / reference) is materialized to a contiguous entity before the write:
94
+
95
+ ```ruby
96
+ big = CArray.float32(100, 4).seq!
97
+ NC.nc_put_vara(ncid, vid, [0, 0], [3, 4], big[10..12, nil])
98
+ # big[10..12, nil] is a CABlock view; layer 1 does .copy on your behalf
99
+ # and passes the resulting entity to nc_put_vara.
100
+ ```
101
+
102
+ A **masked** CArray is *refused*. Layer 1 does no fill encoding — that's layer 2's job (via the codec). Strip the mask first:
103
+
104
+ ```ruby
105
+ ca = CA_SHORT([1, 2, 3])
106
+ ca[1] = UNDEF
107
+ NC.nc_put_vara(ncid, vid, [0], [3], ca) # ArgumentError
108
+ NC.nc_put_vara(ncid, vid, [0], [3], ca.strip_mask(-32768)) # OK, fill baked in
109
+ ```
110
+
111
+ ## `nc_get_var1` / `nc_put_var1` — scalar
112
+
113
+ These are pure passthrough (the netcdf gem handles them directly), included in layer 1 so the mixin style has them:
114
+
115
+ ```ruby
116
+ value = NC.nc_get_var1(ncid, vid, [0, 0]) # Numeric
117
+ NC.nc_put_var1(ncid, vid, [0, 0], 273.15) # writes one cell
118
+ ```
119
+
120
+ Layer 2's scalar decode/encode wraps these calls in a 1-cell CArray to run through the codec — see [Codecs](04_codec.md).
121
+
122
+ ## Definition, inquiry, attribute — all passthrough
123
+
124
+ The netcdf gem covers everything else. In layer-1 code, use them bare (via mixin) or fully qualified:
125
+
126
+ ```ruby
127
+ # create + define
128
+ ncid = NC.nc_create("out.nc", NC::NC_CLOBBER | NC::NC_NETCDF4)
129
+ tid = NC.nc_def_dim(ncid, "time", 24)
130
+ vid = NC.nc_def_var(ncid, "temp", NC::NC_FLOAT, [tid])
131
+ NC.nc_put_att(ncid, vid, "units", "K")
132
+ NC.nc_enddef(ncid)
133
+
134
+ # write and close
135
+ NC.nc_put_var(ncid, vid, CArray.float32(24).seq!)
136
+ NC.nc_close(ncid)
137
+
138
+ # inquire
139
+ ncid = NC.nc_open("out.nc", NC::NC_NOWRITE)
140
+ NC.nc_inq_ndims(ncid) # => 1
141
+ NC.nc_inq_nvars(ncid) # => 1
142
+ NC.nc_inq_varname(ncid, 0) # => "temp"
143
+ NC.nc_inq_vardimid(ncid, 0) # => [0]
144
+ NC.nc_inq_dimlen(ncid, 0) # => 24
145
+ NC.nc_close(ncid)
146
+ ```
147
+
148
+ Any of these can also be reached as `::NetCDF.nc_*` (unchanged), or through layer 2's higher-level surface.
149
+
150
+ ## Coexisting with `::NetCDF` and future `Numo::NetCDF`
151
+
152
+ Layer 1 does **not** modify `::NetCDF`. It only adds behavior in the `CA::NetCDF` namespace. So an app that mixes carray-netcdf with a hypothetical `Numo::NetCDF` gem (which would follow the same pattern in its own namespace) can call:
153
+
154
+ ```ruby
155
+ CA::NetCDF.nc_get_vara(ncid, vid, s, c) # -> CArray
156
+ Numo::NetCDF.nc_get_vara(ncid, vid, s, c) # -> Numo::NArray (hypothetical)
157
+ ::NetCDF.nc_get_vara(ncid, vid, s, c, buf) # -> buf, any MemoryView producer (untouched)
158
+ ```
159
+
160
+ with no clashes. This coexistence is exactly why layer 1 avoids monkey-patching `::NetCDF`.
@@ -0,0 +1,345 @@
1
+ # Cookbook
2
+
3
+ End-to-end scenarios. Each example is self-contained; copy, adapt, run.
4
+
5
+ ## 1. Write a CF-1.7 hourly temperature file
6
+
7
+ ```ruby
8
+ require "carray-netcdf"
9
+ NC = CA::NetCDF
10
+
11
+ NC::File.create("hourly_temp.nc") do |nc|
12
+ # Dimensions
13
+ nc.def_dim("time", :unlimited)
14
+ nc.def_dim("lat", 73)
15
+ nc.def_dim("lon", 144)
16
+
17
+ # Coordinate variables
18
+ nc.def_var("time", :int, ["time"],
19
+ standard_name: "time",
20
+ units: "hours since 2022-01-01T00:00:00Z",
21
+ calendar: "standard")
22
+ nc.def_var("lat", :float, ["lat"],
23
+ standard_name: "latitude", units: "degrees_north")
24
+ nc.def_var("lon", :float, ["lon"],
25
+ standard_name: "longitude", units: "degrees_east")
26
+
27
+ # Data variable
28
+ nc.def_var("temp", :float, ["time", "lat", "lon"],
29
+ standard_name: "air_temperature",
30
+ units: "K",
31
+ _FillValue: -999.9)
32
+
33
+ # Globals
34
+ nc.def_att("Conventions", "CF-1.7")
35
+ nc.def_att("title", "Synthetic hourly temperature")
36
+
37
+ # Coord data
38
+ nc["lat"] = CArray.float32(73) { |i| -90.0 + i * 2.5 }
39
+ nc["lon"] = CArray.float32(144) { |i| i * 2.5 }
40
+ nc["time"] = CArray.time_series("2022-01-01T00:00:00Z", count: 24, unit: :h)
41
+
42
+ # Data (24 hourly slabs)
43
+ 24.times do |t|
44
+ nc["temp", t, nil, nil] = CArray.float32(73, 144) { |i, j|
45
+ 273.15 + 15.0 * Math.cos(i * 0.05) + 5.0 * Math.sin(j * 0.05 + t * 0.1)
46
+ }
47
+ end
48
+ end
49
+ ```
50
+
51
+ Read it back:
52
+
53
+ ```ruby
54
+ NC::File.open("hourly_temp.nc") do |nc|
55
+ nc.att("Conventions") # => "CF-1.7"
56
+
57
+ t = nc["time"].to_ca
58
+ t.class # => CATime
59
+ t.first.to_s # => "2022-01-01T00:00:00Z"
60
+ t.last.to_s # => "2022-01-01T23:00:00Z"
61
+
62
+ temp = nc["temp"].to_ca # decoded (fill -> mask if present)
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
69
+
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]
75
+ end
76
+ ```
77
+
78
+ ## 3. Pack a physical field into a short (satellite-style storage)
79
+
80
+ Storing a physical range that fits comfortably in `short` saves half the space of `float`. CF's `scale_factor` / `add_offset` handle it:
81
+
82
+ ```ruby
83
+ NC::File.create("packed.nc") do |nc|
84
+ nc.def_dim("n", 4)
85
+ nc.def_var("temp", :short, ["n"],
86
+ units: "K",
87
+ scale_factor: 0.01, # 0.01 K resolution
88
+ add_offset: 250.0, # centered around 250 K
89
+ _FillValue: -32768,
90
+ valid_range: [-10000, 10000]) # ±100 K around offset, in packed space
91
+ nc.def_att("Conventions", "CF-1.7")
92
+
93
+ # Write physical values; the codec packs them into short automatically.
94
+ nc["temp"] = CA_DOUBLE([250.00, 275.00, 300.00, 325.00])
95
+ end
96
+ ```
97
+
98
+ Round-trip verification:
99
+
100
+ ```ruby
101
+ # Physical view (default codec = CF)
102
+ NC::File.open("packed.nc") do |nc|
103
+ nc["temp"].to_ca.to_a # => [250.00, 275.00, 300.00, 325.00]
104
+ end
105
+
106
+ # Raw view (Identity codec)
107
+ NC::File.open("packed.nc", codec: :identity) do |nc|
108
+ nc["temp"].to_ca.to_a # => [0, 2500, 5000, 7500]
109
+ end
110
+ ```
111
+
112
+ Optionally enforce `valid_range` masking:
113
+
114
+ ```ruby
115
+ NC::File.open("packed.nc", codec: [:cf, mask_valid_range: true]) do |nc|
116
+ nc["temp"].to_ca # cells outside [-10000, 10000] packed -> UNDEF
117
+ end
118
+ ```
119
+
120
+ ## 4. Stream a large variable one slab at a time
121
+
122
+ ```ruby
123
+ require "carray-netcdf"
124
+ NC = CA::NetCDF
125
+
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.
152
+
153
+ ```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
+ NC::File.open("hourly_temp.nc", :write) do |nc|
185
+ cur = nc.dim("time").length # e.g. 24
186
+
187
+ new_slice = CArray.float32(73, 144) { |i, j| 273.15 + Math.sin(i*0.1) }
188
+ nc["temp", cur, nil, nil] = new_slice # writes at t=cur; time grows to cur+1
189
+
190
+ nc["time", cur] = CArray.time("2022-01-02T00:00:00Z", unit: :h)[0]
191
+ end
192
+ ```
193
+
194
+ ## 6. Group your model by subsystem (NetCDF-4)
195
+
196
+ ```ruby
197
+ NC::File.create("model.nc", netcdf4: true) do |nc|
198
+ nc.def_dim("time", :unlimited)
199
+ nc.def_att("Conventions", "CF-1.7")
200
+
201
+ ocean = nc.def_group("ocean")
202
+ ocean.def_dim("depth", 50)
203
+ ocean.def_var("temperature", :float, ["time", "depth"], units: "degC")
204
+ ocean.def_var("salinity", :float, ["time", "depth"], units: "PSU")
205
+
206
+ atm = nc.def_group("atmosphere")
207
+ atm.def_dim("altitude", 30)
208
+ atm.def_var("pressure", :float, ["time", "altitude"], units: "hPa")
209
+ atm.def_var("temperature", :float, ["time", "altitude"], units: "K")
210
+
211
+ # Timeline coordinate at the root
212
+ nc.def_var("time", :int, ["time"],
213
+ units: "hours since 2024-01-01T00:00:00Z", calendar: "standard")
214
+
215
+ # Write some data
216
+ T = 6
217
+ nc["time"] = CArray.time_series("2024-01-01T00:00:00Z", count: T, unit: :h)
218
+
219
+ T.times do |t|
220
+ ocean["temperature", t, nil] = CArray.float32(50) { |z| 25.0 - z * 0.4 }
221
+ ocean["salinity", t, nil] = CArray.float32(50) { |z| 35.0 + z * 0.01 }
222
+ atm["pressure", t, nil] = CArray.float32(30) { |z| 1013.0 * Math.exp(-z / 8.0) }
223
+ atm["temperature", t, nil] = CArray.float32(30) { |z| 288.15 - z * 6.5 }
224
+ end
225
+ end
226
+ ```
227
+
228
+ Read via full path:
229
+
230
+ ```ruby
231
+ NC::File.open("model.nc") do |nc|
232
+ nc["/ocean/temperature"].shape # => [6, 50]
233
+ nc["/atmosphere/pressure"][0, nil] # 1-D CArray of shape [30]
234
+ nc.group("ocean").dims["depth"].length # => 50
235
+ nc["/time"].to_ca # CATime (CF codec applied)
236
+ end
237
+ ```
238
+
239
+ ## 7. Inspect a mystery file quickly
240
+
241
+ ```ruby
242
+ NC::File.open("mystery.nc") do |nc|
243
+ puts "format: #{nc.format}"
244
+ puts "conventions: #{nc.att('Conventions') || '(none)'}"
245
+ puts "codec: #{nc.codec.class.name.split('::').last}"
246
+ puts
247
+
248
+ puts "dimensions:"
249
+ nc.dims.each { |n, d| puts " #{n} = #{d.length}#{' (unlimited)' if d.unlimited?}" }
250
+ puts
251
+
252
+ puts "variables:"
253
+ nc.vars.each do |n, v|
254
+ puts " #{v.data_type} #{n}#{v.shape.inspect}"
255
+ v.atts.each { |k, val| puts " :#{k} = #{val.inspect}" }
256
+ end
257
+ puts
258
+
259
+ puts "global attributes:"
260
+ nc.atts.each { |k, v| puts " :#{k} = #{v.inspect}" }
261
+
262
+ unless nc.groups.empty?
263
+ puts
264
+ puts "groups:"
265
+ nc.groups.each { |n, g| puts " #{g.path} (#{g.vars.size} vars, #{g.groups.size} subgroups)" }
266
+ end
267
+ end
268
+ ```
269
+
270
+ ## 8. Round-trip decoded vs raw for a single variable
271
+
272
+ Handy when a file behaves unexpectedly and you want to see what the codec is doing:
273
+
274
+ ```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}"
278
+ end
279
+
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}"
284
+ end
285
+ ```
286
+
287
+ ## 9. Write a small file with a bit-exact custom codec
288
+
289
+ ```ruby
290
+ # A minimal codec that only masks _FillValue but skips scale/offset/time.
291
+ class OnlyFillCodec
292
+ def decode(var, ca)
293
+ fill = var.att("_FillValue") or return ca
294
+ out = ca.copy
295
+ out[:eq, fill] = UNDEF
296
+ out
297
+ end
298
+
299
+ def encode(var, ca)
300
+ return ca unless ca.is_a?(CArray) && ca.has_mask?
301
+ fill = var.att("_FillValue") or
302
+ raise ArgumentError, "no _FillValue to encode masked cells"
303
+ ca.strip_mask(fill)
304
+ end
305
+ end
306
+
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
310
+ 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
+
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 "}"
342
+ end
343
+
344
+ NC::File.open("mystery.nc") { |nc| cdl_summary(nc) }
345
+ ```