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.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- 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")
|