microstructure-tpw 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. odas_tpw/scor160/spectral.py +482 -0
@@ -0,0 +1,346 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Combined NetCDF assembly — merge binned data across all files.
3
+
4
+ Reference: Code/save2combo.m, Code/glue_widthwise.m, Code/glue_lengthwise.m,
5
+ Code/save2NetCDF.m
6
+ """
7
+
8
+ import contextlib
9
+ import logging
10
+ import os
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import xarray as xr
16
+
17
+ from odas_tpw.perturb.netcdf_schema import GLOBAL_ATTRS, apply_schema
18
+
19
+
20
+ def _atomic_to_netcdf(ds: xr.Dataset, out_path: Path, encoding: dict) -> None:
21
+ """Write *ds* to *out_path* atomically (hidden temp file + os.replace).
22
+
23
+ A direct ``to_netcdf(out_path)`` publishes the file incrementally, so a
24
+ concurrent reader -- e.g. ``perturb-plot`` (the mixing product opens two
25
+ combos) running while the pipeline finishes -- can read a truncated
26
+ ``combo.nc``. ``os.replace`` is atomic within a filesystem, so the reader
27
+ sees either the old file or the complete new one. Mirrors
28
+ ``pipeline._atomic_to_netcdf`` (kept local to avoid a circular import),
29
+ adding ``encoding`` support.
30
+ """
31
+ tmp = out_path.with_name(f".{out_path.name}.{os.getpid()}.tmp")
32
+ try:
33
+ ds.to_netcdf(tmp, encoding=encoding)
34
+ os.replace(tmp, out_path)
35
+ except BaseException:
36
+ with contextlib.suppress(OSError):
37
+ os.unlink(tmp)
38
+ raise
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def _glue_widthwise(datasets: list[xr.Dataset]) -> xr.Dataset:
44
+ """Glue depth-binned datasets widthwise (bins x all profiles).
45
+
46
+ Each input dataset has dims (bin, profile). Profiles are concatenated
47
+ along the profile dimension.
48
+ """
49
+ if not datasets:
50
+ return xr.Dataset()
51
+ # join="exact": the binned files share one global bin grid, so a mismatched
52
+ # 'bin' coordinate (a stale/extra file, or a re-run at a different depth
53
+ # range) should fail loudly rather than silently outer-join + NaN-pad.
54
+ return xr.concat(datasets, dim="profile", join="exact")
55
+
56
+
57
+ def _glue_lengthwise(datasets: list[xr.Dataset]) -> xr.Dataset:
58
+ """Glue time-binned datasets lengthwise (concatenate time bins).
59
+
60
+ Each input dataset has a time dimension.
61
+ """
62
+ if not datasets:
63
+ return xr.Dataset()
64
+ return xr.concat(datasets, dim="time")
65
+
66
+
67
+ def make_combo(
68
+ binned_dir: str | Path,
69
+ output_dir: str | Path,
70
+ schema: dict,
71
+ netcdf_attrs: dict | None = None,
72
+ method: str = "depth",
73
+ ) -> Path | None:
74
+ """Merge all binned NetCDFs into a single combo.nc.
75
+
76
+ Parameters
77
+ ----------
78
+ binned_dir : Path
79
+ Directory containing per-file binned NetCDFs.
80
+ output_dir : Path
81
+ Directory for combo output.
82
+ schema : dict
83
+ CF/ACDD schema for variable metadata.
84
+ netcdf_attrs : dict, optional
85
+ Additional global attributes from config.
86
+ method : str
87
+ "depth" (widthwise glue) or "time" (lengthwise glue).
88
+
89
+ Returns
90
+ -------
91
+ Path or None
92
+ Path to combo.nc, or None if no input files.
93
+ """
94
+ binned_dir = Path(binned_dir)
95
+ output_dir = Path(output_dir)
96
+ output_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ nc_files = sorted(binned_dir.glob("*.nc"))
99
+ if not nc_files:
100
+ return None
101
+
102
+ # xarray decodes datetime variables and stores their original ``units``
103
+ # in attrs. The CF encoder later refuses to overwrite that attr when
104
+ # we re-write — so for binned files (which we always re-encode) it is
105
+ # cleaner to keep numeric times throughout.
106
+ datasets = [xr.open_dataset(f, decode_times=False) for f in nc_files]
107
+
108
+ try:
109
+ combo = _glue_widthwise(datasets) if method == "depth" else _glue_lengthwise(datasets)
110
+ # Materialize before closing the source handles below: the concat is
111
+ # lazy, so a later operation (or to_netcdf) would otherwise re-read the
112
+ # closed files via xarray's caching layer -- brittle and needless I/O.
113
+ combo.load()
114
+ finally:
115
+ # Close every opened handle even if the concat raises (e.g. mismatched
116
+ # bin grids), so a failed combo doesn't leak file descriptors.
117
+ for ds in datasets:
118
+ ds.close()
119
+
120
+ # Chronologically sort the depth-combo profiles so time_coverage_start /
121
+ # end agree with stime[0] / stime[-1]. ACDD's checker compares these
122
+ # explicitly and raises a "Date time mismatch" warning otherwise.
123
+ if method == "depth" and "profile" in combo.dims:
124
+ if "stime" in combo:
125
+ sk = combo["stime"].values
126
+ order = np.argsort(np.where(np.isfinite(sk), sk, np.inf))
127
+ combo = combo.isel(profile=order)
128
+ # Always reset to a contiguous unique profile index: concat stacks each
129
+ # file's own [0..n) coordinate, so the raw 'profile' coord has duplicate
130
+ # values (e.g. [0,1,0,1]) -- reassign even when 'stime' is absent.
131
+ combo = combo.assign_coords(profile=np.arange(combo.sizes["profile"], dtype=np.int32))
132
+ elif method == "time" and "time" in combo.coords and combo["time"].size:
133
+ # CF requires the time coordinate to be strictly monotonic; per-file
134
+ # outputs concatenated in filename order are not (e.g. ARCTERX has
135
+ # ``data_*`` April + ``OH3465_*`` May files alphabetically reversed).
136
+ tk = combo["time"].values
137
+ order = np.argsort(tk)
138
+ combo = combo.isel(time=order)
139
+ # argsort alone leaves NaN times and duplicate timestamps (overlapping
140
+ # per-file windows), which violate CF strict monotonicity. Drop NaN and
141
+ # coalesce exact duplicates (keep first) on the now-sorted axis.
142
+ tk = combo["time"].values
143
+ finite = np.isfinite(tk)
144
+ keep = finite.copy()
145
+ keep[1:] &= tk[1:] != tk[:-1]
146
+ # Only filter when it leaves at least one sample: an all-NaN-time combo
147
+ # (no valid coordinate at all) is kept intact so it stays writable -- the
148
+ # downstream coverage-attr logic already skips coverage for all-NaN time.
149
+ if keep.any() and not keep.all():
150
+ # Non-finite and duplicate drops are disjoint (NaN != anything, so a
151
+ # duplicate cell is always finite), hence n_dup = total - n_nan.
152
+ n_nan = int((~finite).sum())
153
+ n_dropped = int(keep.size - int(keep.sum()))
154
+ n_dup = n_dropped - n_nan
155
+ if n_dropped:
156
+ logger.warning(
157
+ "make_combo(time): dropped %d duplicate and %d non-finite "
158
+ "timestamp(s) of %d for CF strict monotonicity",
159
+ n_dup,
160
+ n_nan,
161
+ int(keep.size),
162
+ )
163
+ combo = combo.isel(time=np.flatnonzero(keep))
164
+
165
+ # Apply schema
166
+ combo = apply_schema(combo, schema)
167
+
168
+ # Apply global attributes — featureType is the one GLOBAL_ATTRS entry
169
+ # that legitimately differs by combo style (depth ⇒ ``profile``,
170
+ # time ⇒ ``timeSeries``), so resolve it after copying the defaults.
171
+ combo.attrs.update(GLOBAL_ATTRS)
172
+ # CF §9 featureType: depth combos are profile sets; CTD time combos are
173
+ # along a moving ship track, which CF calls ``trajectory``.
174
+ combo.attrs["featureType"] = "profile" if method == "depth" else "trajectory"
175
+ # CF §9.4: a trajectory-featured dataset must carry an instance variable
176
+ # with ``cf_role="trajectory_id"``. For our single-platform CTD combos
177
+ # a scalar is sufficient.
178
+ if method == "time" and "trajectory_id" not in combo:
179
+ traj_id = combo.attrs.get("id") or "platform_track"
180
+ combo["trajectory_id"] = xr.DataArray(
181
+ np.asarray(str(traj_id), dtype="S64"),
182
+ attrs={"cf_role": "trajectory_id", "long_name": "trajectory identifier"},
183
+ )
184
+ combo.attrs["date_created"] = datetime.now(UTC).isoformat()
185
+ combo.attrs["history"] = f"Created by perturb on {datetime.now(UTC).isoformat()}"
186
+
187
+ if netcdf_attrs:
188
+ for k, v in netcdf_attrs.items():
189
+ if v is not None:
190
+ combo.attrs[k] = v
191
+
192
+ # Auto-fill geospatial/temporal attributes
193
+ has_lat = "lat" in combo and np.any(np.isfinite(combo["lat"].values))
194
+ has_lon = "lon" in combo and np.any(np.isfinite(combo["lon"].values))
195
+ if has_lat:
196
+ lat_vals = combo["lat"].values
197
+ combo.attrs.setdefault("geospatial_lat_min", float(np.nanmin(lat_vals)))
198
+ combo.attrs.setdefault("geospatial_lat_max", float(np.nanmax(lat_vals)))
199
+ combo.attrs.setdefault("geospatial_lat_units", "degrees_north")
200
+ combo.attrs.setdefault("geospatial_lat_resolution", "point")
201
+ if has_lon:
202
+ lon_vals = combo["lon"].values
203
+ combo.attrs.setdefault("geospatial_lon_min", float(np.nanmin(lon_vals)))
204
+ combo.attrs.setdefault("geospatial_lon_max", float(np.nanmax(lon_vals)))
205
+ combo.attrs.setdefault("geospatial_lon_units", "degrees_east")
206
+ combo.attrs.setdefault("geospatial_lon_resolution", "point")
207
+ if has_lat and has_lon:
208
+ # WGS84 is the standard for surface oceanographic GPS feeds.
209
+ combo.attrs.setdefault("geospatial_bounds_crs", "EPSG:4326")
210
+ lat_min = combo.attrs["geospatial_lat_min"]
211
+ lat_max = combo.attrs["geospatial_lat_max"]
212
+ lon_min = combo.attrs["geospatial_lon_min"]
213
+ lon_max = combo.attrs["geospatial_lon_max"]
214
+ combo.attrs.setdefault(
215
+ "geospatial_bounds",
216
+ f"POLYGON(({lon_min} {lat_min}, {lon_max} {lat_min}, "
217
+ f"{lon_max} {lat_max}, {lon_min} {lat_max}, {lon_min} {lat_min}))",
218
+ )
219
+
220
+ # Vertical extent: prefer ``depth`` (m, positive down) over ``bin`` or ``P``
221
+ for vname in ("depth", "bin", "P"):
222
+ if vname in combo.coords or vname in combo:
223
+ vals = combo[vname].values
224
+ if np.any(np.isfinite(vals)):
225
+ combo.attrs.setdefault("geospatial_vertical_min", float(np.nanmin(vals)))
226
+ combo.attrs.setdefault("geospatial_vertical_max", float(np.nanmax(vals)))
227
+ combo.attrs.setdefault("geospatial_vertical_units", "m")
228
+ # EPSG:5831 = "Mean Sea Level depth" (positive down, in
229
+ # meters) — the right vertical CRS for our depth coord.
230
+ combo.attrs.setdefault("geospatial_bounds_vertical_crs", "EPSG:5831")
231
+ combo.attrs.setdefault("geospatial_vertical_resolution", "1.0 m")
232
+ break
233
+
234
+ # Time coverage: prefer per-profile stime/etime (depth combos), fall back
235
+ # to ``time`` for time combos (CTD). ISO 8601 with timezone designator,
236
+ # plus a derived duration (ACDD recommended).
237
+ t_start = None
238
+ t_end = None
239
+ if "stime" in combo and "etime" in combo:
240
+ stime = combo["stime"].values
241
+ etime = combo["etime"].values
242
+ if np.any(np.isfinite(stime)) and np.any(np.isfinite(etime)):
243
+ t_start = np.datetime64("1970-01-01") + np.timedelta64(
244
+ int(np.nanmin(stime)), "s"
245
+ )
246
+ t_end = np.datetime64("1970-01-01") + np.timedelta64(int(np.nanmax(etime)), "s")
247
+ elif "time" in combo:
248
+ # ``decode_times=False`` keeps numeric time on read, so values are
249
+ # epoch seconds — convert via timedelta64 for ISO formatting.
250
+ t = combo["time"].values
251
+ if t.size and np.any(np.isfinite(t.astype("float64"))):
252
+ t_start = np.datetime64("1970-01-01") + np.timedelta64(
253
+ int(np.nanmin(t)), "s"
254
+ )
255
+ t_end = np.datetime64("1970-01-01") + np.timedelta64(int(np.nanmax(t)), "s")
256
+ if t_start is not None and t_end is not None:
257
+ combo.attrs.setdefault("time_coverage_start", f"{t_start}Z")
258
+ combo.attrs.setdefault("time_coverage_end", f"{t_end}Z")
259
+ # ISO 8601 duration — avoid pulling in a heavy dep just for this.
260
+ secs = int((t_end - t_start) / np.timedelta64(1, "s"))
261
+ if secs >= 0:
262
+ d, rem = divmod(secs, 86400)
263
+ h, rem = divmod(rem, 3600)
264
+ m, s = divmod(rem, 60)
265
+ iso_dur = "P"
266
+ if d:
267
+ iso_dur += f"{d}D"
268
+ if h or m or s or iso_dur == "P":
269
+ iso_dur += "T"
270
+ if h:
271
+ iso_dur += f"{h}H"
272
+ if m:
273
+ iso_dur += f"{m}M"
274
+ if s or iso_dur == "PT":
275
+ iso_dur += f"{s}S"
276
+ combo.attrs.setdefault("time_coverage_duration", iso_dur)
277
+ # ACDD recommends a typical sampling interval — for profile combos
278
+ # the median between successive profile starts is the right choice.
279
+ if "stime" in combo and combo["stime"].size > 1:
280
+ stimes = combo["stime"].values
281
+ stimes = stimes[np.isfinite(stimes)]
282
+ if stimes.size > 1:
283
+ stimes_sorted = np.sort(stimes)
284
+ med = float(np.median(np.diff(stimes_sorted)))
285
+ if med > 0:
286
+ secs = round(med)
287
+ h, rem = divmod(secs, 3600)
288
+ m, s = divmod(rem, 60)
289
+ res = "PT"
290
+ if h:
291
+ res += f"{h}H"
292
+ if m:
293
+ res += f"{m}M"
294
+ if s or res == "PT":
295
+ res += f"{s}S"
296
+ combo.attrs.setdefault("time_coverage_resolution", res)
297
+
298
+ # Auto-derive ``id`` from output path if the user didn't set it
299
+ combo.attrs.setdefault("id", output_dir.name)
300
+
301
+ # CF profile featureType requires the profile coordinate to be int (not
302
+ # int64) and to carry cf_role. xarray's default ``np.arange`` produces
303
+ # int64 on 64-bit platforms, which compliance-checker rejects.
304
+ if "profile" in combo.dims and "profile" in combo.coords:
305
+ if combo["profile"].dtype.kind == "i" and combo["profile"].dtype.itemsize > 4:
306
+ combo = combo.assign_coords(profile=combo["profile"].astype(np.int32))
307
+ combo["profile"].attrs.setdefault("cf_role", "profile_id")
308
+ combo["profile"].attrs.setdefault("long_name", "profile index")
309
+
310
+ # Strip the auto-applied _FillValue from coordinate variables — CF-1.13
311
+ # §2.5.1 forbids _FillValue on coordinates. xarray writes one by default
312
+ # for floats; clear it via encoding instead of mutating attrs.
313
+ encoding: dict = {}
314
+ for cname in combo.coords:
315
+ encoding[cname] = {"_FillValue": None}
316
+
317
+ out_path = output_dir / "combo.nc"
318
+ _atomic_to_netcdf(combo, out_path, encoding)
319
+ return out_path
320
+
321
+
322
+ def make_ctd_combo(
323
+ ctd_dir: str | Path,
324
+ output_dir: str | Path,
325
+ schema: dict,
326
+ netcdf_attrs: dict | None = None,
327
+ ) -> Path | None:
328
+ """Concatenate all CTD time-binned files into a single combo.
329
+
330
+ Parameters
331
+ ----------
332
+ ctd_dir : Path
333
+ Directory containing per-file CTD NetCDFs.
334
+ output_dir : Path
335
+ Directory for combo output.
336
+ schema : dict
337
+ CF/ACDD schema.
338
+ netcdf_attrs : dict, optional
339
+ Additional global attributes.
340
+
341
+ Returns
342
+ -------
343
+ Path or None
344
+ Path to combo.nc, or None if no input files.
345
+ """
346
+ return make_combo(ctd_dir, output_dir, schema, netcdf_attrs, method="time")