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,761 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Depth/time binning engine for profiles, dissipation, and chi.
|
|
3
|
+
|
|
4
|
+
Reference: Code/bin_by_real.m, Code/profile2binned.m, Code/diss2binned.m,
|
|
5
|
+
Code/chi2binned.m
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import xarray as xr
|
|
15
|
+
|
|
16
|
+
from odas_tpw.perturb.logging_setup import stage_log
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Per-profile NetCDFs are named ``<pfile_stem>_prof###.nc`` by
|
|
21
|
+
# :func:`odas_tpw.rsi.profile.extract_profiles`. The binning step's
|
|
22
|
+
# per-input-file log scope groups them back by source .p file rather than
|
|
23
|
+
# splitting one log per profile, so we strip the suffix to recover the
|
|
24
|
+
# .p stem. Inputs that don't match the pattern (third-party NetCDFs,
|
|
25
|
+
# already-binned outputs) keep their full stem.
|
|
26
|
+
_PROF_SUFFIX_RE = re.compile(r"_prof\d+$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _source_stem(profile_file: Path | str) -> str:
|
|
30
|
+
"""Return the source ``.p``-file stem for a per-profile NetCDF path."""
|
|
31
|
+
return _PROF_SUFFIX_RE.sub("", Path(profile_file).stem)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bin_indices(coords: np.ndarray, bin_edges: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
35
|
+
"""Bin indices plus an in-range mask.
|
|
36
|
+
|
|
37
|
+
Samples with NaN coordinates (np.digitize returns len(edges)) or
|
|
38
|
+
coordinates outside the bin range are EXCLUDED via the mask — the
|
|
39
|
+
previous np.clip silently dumped them into the first/last bin.
|
|
40
|
+
Bins are half-open [lo, hi) except the last, which includes its
|
|
41
|
+
upper edge.
|
|
42
|
+
"""
|
|
43
|
+
n_bins = len(bin_edges) - 1
|
|
44
|
+
with np.errstate(invalid="ignore"):
|
|
45
|
+
idx = np.digitize(coords, bin_edges) - 1
|
|
46
|
+
idx = np.where(coords == bin_edges[-1], n_bins - 1, idx)
|
|
47
|
+
in_range = np.isfinite(coords) & (idx >= 0) & (idx < n_bins)
|
|
48
|
+
return idx, in_range
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bin_std(values: np.ndarray, coords: np.ndarray, bin_edges: np.ndarray) -> np.ndarray:
|
|
52
|
+
"""NaN-aware std-per-bin via bincount (population std, ddof=0).
|
|
53
|
+
|
|
54
|
+
Equivalent to a per-bin ``np.nanstd`` loop, but avoids the
|
|
55
|
+
O(n_bins * n_samples) mask broadcast.
|
|
56
|
+
"""
|
|
57
|
+
n_bins = len(bin_edges) - 1
|
|
58
|
+
idx, in_range = _bin_indices(coords, bin_edges)
|
|
59
|
+
finite = in_range & np.isfinite(values)
|
|
60
|
+
idx_f = idx[finite]
|
|
61
|
+
vals_f = values[finite]
|
|
62
|
+
counts = np.bincount(idx_f, minlength=n_bins)
|
|
63
|
+
sums = np.bincount(idx_f, weights=vals_f, minlength=n_bins)
|
|
64
|
+
sums_sq = np.bincount(idx_f, weights=vals_f * vals_f, minlength=n_bins)
|
|
65
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
66
|
+
means = np.where(counts > 0, sums / np.maximum(counts, 1), 0.0)
|
|
67
|
+
var = sums_sq / np.maximum(counts, 1) - means * means
|
|
68
|
+
var = np.maximum(var, 0.0) # cancellation safety
|
|
69
|
+
std = np.where(counts > 1, np.sqrt(var), np.nan)
|
|
70
|
+
return std
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _bin_array(
|
|
74
|
+
values: np.ndarray,
|
|
75
|
+
coords: np.ndarray,
|
|
76
|
+
bin_edges: np.ndarray,
|
|
77
|
+
agg_func,
|
|
78
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
79
|
+
"""Core binning: assign values to bins and aggregate.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
values : ndarray, 1D
|
|
84
|
+
coords : ndarray, 1D (same length as values)
|
|
85
|
+
bin_edges : ndarray, 1D (n_bins + 1)
|
|
86
|
+
agg_func : callable (e.g. np.nanmean, np.nanmedian)
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
binned : ndarray, shape (n_bins,)
|
|
91
|
+
counts : ndarray, shape (n_bins,) — int, in-range samples per bin
|
|
92
|
+
(independent of value finiteness; see _bin_finite_counts).
|
|
93
|
+
"""
|
|
94
|
+
n_bins = len(bin_edges) - 1
|
|
95
|
+
idx, in_range = _bin_indices(coords, bin_edges)
|
|
96
|
+
idx = idx[in_range]
|
|
97
|
+
values = values[in_range]
|
|
98
|
+
|
|
99
|
+
counts = np.bincount(idx, minlength=n_bins).astype(int)
|
|
100
|
+
|
|
101
|
+
if agg_func is np.nanmean:
|
|
102
|
+
# Vectorize the dominant case: NaN-aware mean via two bincounts.
|
|
103
|
+
finite = np.isfinite(values)
|
|
104
|
+
idx_f = idx[finite]
|
|
105
|
+
vals_f = values[finite]
|
|
106
|
+
sums = np.bincount(idx_f, weights=vals_f, minlength=n_bins)
|
|
107
|
+
finite_counts = np.bincount(idx_f, minlength=n_bins)
|
|
108
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
109
|
+
binned = np.where(finite_counts > 0, sums / finite_counts, np.nan)
|
|
110
|
+
return binned, counts
|
|
111
|
+
|
|
112
|
+
# General fallback (e.g. nanmedian): sort once, then each bin is a
|
|
113
|
+
# contiguous slice — avoids the per-bin O(n_samples) mask broadcast.
|
|
114
|
+
order = np.argsort(idx, kind="stable")
|
|
115
|
+
sorted_idx = idx[order]
|
|
116
|
+
sorted_vals = values[order]
|
|
117
|
+
splits = np.searchsorted(sorted_idx, np.arange(n_bins + 1))
|
|
118
|
+
binned = np.full(n_bins, np.nan)
|
|
119
|
+
for i in range(n_bins):
|
|
120
|
+
sl = sorted_vals[splits[i] : splits[i + 1]]
|
|
121
|
+
if sl.size > 0:
|
|
122
|
+
binned[i] = agg_func(sl)
|
|
123
|
+
return binned, counts
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _bin_finite_counts(
|
|
127
|
+
values: np.ndarray, coords: np.ndarray, bin_edges: np.ndarray
|
|
128
|
+
) -> np.ndarray:
|
|
129
|
+
"""Per-bin count of finite values — the effective averaging N.
|
|
130
|
+
|
|
131
|
+
After QC NaN'ing, the in-range sample count overstates how many
|
|
132
|
+
values actually entered a bin mean; this is the honest N for
|
|
133
|
+
weighting bins or building confidence intervals.
|
|
134
|
+
"""
|
|
135
|
+
n_bins = len(bin_edges) - 1
|
|
136
|
+
idx, in_range = _bin_indices(coords, bin_edges)
|
|
137
|
+
finite = in_range & np.isfinite(values)
|
|
138
|
+
return np.bincount(idx[finite], minlength=n_bins).astype(int)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _bin_or(values: np.ndarray, coords: np.ndarray, bin_edges: np.ndarray) -> np.ndarray:
|
|
142
|
+
"""Bitwise-OR pooling per bin for QC flag bitfields.
|
|
143
|
+
|
|
144
|
+
Arithmetic averaging of flag bits produces meaningless fractional
|
|
145
|
+
values; the OR of all in-bin flags preserves "any sample in this bin
|
|
146
|
+
was flagged with bit X". Empty bins are NaN.
|
|
147
|
+
"""
|
|
148
|
+
n_bins = len(bin_edges) - 1
|
|
149
|
+
idx, in_range = _bin_indices(coords, bin_edges)
|
|
150
|
+
idx_v = idx[in_range]
|
|
151
|
+
vals_v = values[in_range].astype(np.int64)
|
|
152
|
+
pooled = np.zeros(n_bins, dtype=np.int64)
|
|
153
|
+
np.bitwise_or.at(pooled, idx_v, vals_v)
|
|
154
|
+
counts = np.bincount(idx_v, minlength=n_bins)
|
|
155
|
+
return np.where(counts > 0, pooled.astype(float), np.nan)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_agg_func(aggregation: str):
|
|
159
|
+
"""Return the aggregation function for the given method name."""
|
|
160
|
+
if aggregation == "median":
|
|
161
|
+
return np.nanmedian
|
|
162
|
+
return np.nanmean
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _time_to_seconds(values: np.ndarray) -> np.ndarray:
|
|
166
|
+
"""Convert numeric or decoded CF time values to float seconds.
|
|
167
|
+
|
|
168
|
+
Numeric arrays are already treated as seconds. ``datetime64`` arrays are
|
|
169
|
+
converted to Unix epoch seconds, giving CF time variables with different
|
|
170
|
+
``seconds since ...`` origins a common basis before bin-edge arithmetic.
|
|
171
|
+
"""
|
|
172
|
+
arr = np.asarray(values)
|
|
173
|
+
if arr.dtype.kind == "M":
|
|
174
|
+
ns = arr.astype("datetime64[ns]").astype("int64")
|
|
175
|
+
seconds = ns.astype(float) / 1e9
|
|
176
|
+
seconds[ns == np.iinfo(np.int64).min] = np.nan
|
|
177
|
+
return seconds
|
|
178
|
+
if arr.dtype.kind in ("f", "i", "u"):
|
|
179
|
+
return arr.astype(float)
|
|
180
|
+
try:
|
|
181
|
+
dt = arr.astype("datetime64[ns]")
|
|
182
|
+
except (TypeError, ValueError):
|
|
183
|
+
return np.full(arr.shape, np.nan, dtype=float)
|
|
184
|
+
ns = dt.astype("int64")
|
|
185
|
+
seconds = ns.astype(float) / 1e9
|
|
186
|
+
seconds[ns == np.iinfo(np.int64).min] = np.nan
|
|
187
|
+
return seconds
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_SCALAR_NAMES = ("lat", "lon", "stime", "etime")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_DEPTH_CANDIDATES = ("depth", "P", "P_mean")
|
|
194
|
+
|
|
195
|
+
# Latitude used to convert pressure -> depth when a profile carries no ``lat``.
|
|
196
|
+
# gsw.z_from_p's latitude (gravity) dependence is ~3 m at 1000 dbar between the
|
|
197
|
+
# equator and the poles; a mid-latitude default halves that worst-case error
|
|
198
|
+
# (~+/-1.5 m) vs assuming the equator or a pole. Profiles from the perturb
|
|
199
|
+
# pipeline normally carry their GPS ``lat``, so this fallback is rarely used.
|
|
200
|
+
_DEFAULT_BIN_LATITUDE = 45.0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _decoded(var) -> np.ndarray:
|
|
204
|
+
"""Read a ``netCDF4`` variable applying CF mask + scale, fill/missing -> NaN.
|
|
205
|
+
|
|
206
|
+
With CF decoding on (see :func:`_load_profile_snapshot`), ``var[:]`` returns a
|
|
207
|
+
scaled, masked array whenever the variable carries ``_FillValue`` /
|
|
208
|
+
``missing_value`` or ``scale_factor`` / ``add_offset``. We convert masked
|
|
209
|
+
cells to NaN (``np.ma.filled``) so packed or sentinel-filled *float* data are
|
|
210
|
+
decoded to physical values instead of being binned as raw counts / fill
|
|
211
|
+
sentinels. (2026-07-03 review, GPT-5.5 F2.)
|
|
212
|
+
|
|
213
|
+
Exception — plain (non-packed) integer flag bitfields such as ``qc_drop_*``
|
|
214
|
+
are read RAW, bypassing CF mask/scale. netCDF4's auto-mask masks any cell
|
|
215
|
+
equal to the type's *default* fill (255 for ``uint8``) even with no explicit
|
|
216
|
+
``_FillValue`` — but 255 is a legal bitfield value (all 8 flags set), and
|
|
217
|
+
masking it would widen the array to float and defeat the uint OR-pool in
|
|
218
|
+
:func:`_bin_snapshot` (a bitfield must never be averaged). Reading these raw
|
|
219
|
+
preserves the integer dtype and the exact pre-CF-decode values, so their
|
|
220
|
+
binning is unchanged; only genuinely packed integers (which carry
|
|
221
|
+
``scale_factor`` / ``add_offset`` and decode to physical floats) fall through
|
|
222
|
+
to the float path. (2026-07-03 adversarial review of F2.)
|
|
223
|
+
"""
|
|
224
|
+
attrs = var.ncattrs()
|
|
225
|
+
packed = "scale_factor" in attrs or "add_offset" in attrs
|
|
226
|
+
if var.dtype.kind in ("u", "i") and not packed:
|
|
227
|
+
var.set_auto_maskandscale(False) # per-var override of the dataset default
|
|
228
|
+
return np.asarray(var[:])
|
|
229
|
+
|
|
230
|
+
arr = var[:]
|
|
231
|
+
# netCDF4 returns a MaskedArray under CF decoding even when nothing is
|
|
232
|
+
# masked; only widen to float64+NaN when cells are actually filled.
|
|
233
|
+
if np.ma.isMaskedArray(arr) and np.ma.count_masked(arr):
|
|
234
|
+
return np.asarray(np.ma.filled(arr.astype(np.float64), np.nan))
|
|
235
|
+
return np.asarray(arr)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Data-variable attributes carried through binning to the combo. Binning
|
|
239
|
+
# otherwise drops all data-var attrs (they are recreated from the static schema
|
|
240
|
+
# at combo time), which would discard per-run provenance like the in-situ FP07
|
|
241
|
+
# calibration tag. Only this curated set survives, so the combo does not gain a
|
|
242
|
+
# flood of source attrs — schema-controlled keys (units/long_name/...) are
|
|
243
|
+
# unaffected. Extend when another provenance attr needs to reach the products.
|
|
244
|
+
#
|
|
245
|
+
# flag_meanings / flag_masks are the CF bitfield metadata written on the
|
|
246
|
+
# qc_drop_epsilon / qc_drop_chi flags (qc_gate.py); without them the combo's qc
|
|
247
|
+
# field is an attr-less integer whose bits cannot be decoded. combo's
|
|
248
|
+
# apply_schema only *adds* schema keys (it never clears existing attrs), so
|
|
249
|
+
# preserving them here carries them to the product. One caveat: make_combo's
|
|
250
|
+
# xr.concat uses the default combine_attrs="override" (per-variable attrs come
|
|
251
|
+
# from the FIRST binned file), so in the normal homogeneous case (every source
|
|
252
|
+
# QC'd identically) they survive, but a heterogeneous run whose first-sorted
|
|
253
|
+
# source lacks the flags would drop them combo-wide. (#104 U5-3.)
|
|
254
|
+
_PRESERVE_DATAVAR_ATTRS: frozenset[str] = frozenset({"calibration", "flag_meanings", "flag_masks"})
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _load_profile_snapshot(profile_file: Path) -> dict | None:
|
|
258
|
+
"""Read a profile NC once and return a snapshot for binning.
|
|
259
|
+
|
|
260
|
+
Returned dict (or None when the file has no depth/P/P_mean variable):
|
|
261
|
+
|
|
262
|
+
* ``depth`` — 1-D ndarray of the depth/P/P_mean coordinate
|
|
263
|
+
* ``vars`` — ``{var_name: 1-D ndarray}`` for every numeric data var
|
|
264
|
+
that aligns with depth
|
|
265
|
+
* ``scalars`` — ``{lat|lon|stime|etime: float}`` (epoch seconds for time)
|
|
266
|
+
* ``scalar_attrs`` — ``{name: attrs_dict}`` (units/calendar stripped — the
|
|
267
|
+
combo's CF encoder re-emits them on write)
|
|
268
|
+
* ``var_attrs`` — ``{var_name: attrs_dict}`` for the curated
|
|
269
|
+
:data:`_PRESERVE_DATAVAR_ATTRS` carried through to the combo
|
|
270
|
+
|
|
271
|
+
Implementation note: we open with raw ``netCDF4.Dataset`` rather
|
|
272
|
+
than ``xr.open_dataset`` here. xarray's per-open overhead
|
|
273
|
+
(file-manager cache lookup, variable wrapping, dim/coord
|
|
274
|
+
establishment) dominated the per-call cost and we don't need any
|
|
275
|
+
of it — we read a fixed shape of float arrays plus four scalars.
|
|
276
|
+
Time variables are filtered out via the CF ``units`` convention
|
|
277
|
+
(' since ' substring). CF mask + scale decoding is left ON (the
|
|
278
|
+
``netCDF4`` default): reads go through :func:`_decoded`, so
|
|
279
|
+
``_FillValue`` / ``missing_value`` cells become NaN and
|
|
280
|
+
``scale_factor`` / ``add_offset`` packing is applied. This matches
|
|
281
|
+
xarray's ``decode_cf=True`` path — external, packed, or
|
|
282
|
+
partially-written files bin their physical values, not raw sentinels
|
|
283
|
+
or packed integer counts.
|
|
284
|
+
"""
|
|
285
|
+
import netCDF4 as nc
|
|
286
|
+
|
|
287
|
+
ds = nc.Dataset(str(profile_file), "r")
|
|
288
|
+
try:
|
|
289
|
+
ds.set_auto_maskandscale(True) # CF decode; _decoded fills masked -> NaN
|
|
290
|
+
|
|
291
|
+
depth_var = next(
|
|
292
|
+
(n for n in _DEPTH_CANDIDATES if n in ds.variables), None
|
|
293
|
+
)
|
|
294
|
+
if depth_var is None:
|
|
295
|
+
return None
|
|
296
|
+
depth = _decoded(ds.variables[depth_var])
|
|
297
|
+
|
|
298
|
+
scalars: dict[str, float] = {}
|
|
299
|
+
scalar_attrs: dict[str, dict] = {}
|
|
300
|
+
for sname in _SCALAR_NAMES:
|
|
301
|
+
if sname not in ds.variables:
|
|
302
|
+
continue
|
|
303
|
+
sv = ds.variables[sname]
|
|
304
|
+
if sv.shape != ():
|
|
305
|
+
continue
|
|
306
|
+
# Fill-safe: a masked/filled scalar becomes NaN rather than crashing
|
|
307
|
+
# float() or reading a raw sentinel as a real lat/lon/time.
|
|
308
|
+
scalars[sname] = float(np.ma.filled(np.ma.asarray(sv[()]), np.nan))
|
|
309
|
+
scalar_attrs[sname] = {
|
|
310
|
+
a: getattr(sv, a)
|
|
311
|
+
for a in sv.ncattrs()
|
|
312
|
+
if a not in ("units", "calendar")
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# The bin coordinate must be depth in METERS. A true 'depth' variable is
|
|
316
|
+
# already meters (TEOS-10, lat-aware) and used as-is; a pressure fallback
|
|
317
|
+
# (dbar) is converted via gsw.z_from_p using the profile's own latitude
|
|
318
|
+
# when present, else the mid-latitude default (see _DEFAULT_BIN_LATITUDE).
|
|
319
|
+
if depth_var in ("P", "P_mean"):
|
|
320
|
+
import gsw
|
|
321
|
+
|
|
322
|
+
_lat = scalars.get("lat")
|
|
323
|
+
if _lat is None or not np.isfinite(_lat):
|
|
324
|
+
_lat = _DEFAULT_BIN_LATITUDE
|
|
325
|
+
depth = -np.asarray(gsw.z_from_p(np.asarray(depth, dtype=float), _lat), dtype=float)
|
|
326
|
+
|
|
327
|
+
n_depth = len(depth)
|
|
328
|
+
data: dict[str, np.ndarray] = {}
|
|
329
|
+
var_attrs: dict[str, dict] = {}
|
|
330
|
+
for vname, var in ds.variables.items():
|
|
331
|
+
if vname == depth_var or vname in _SCALAR_NAMES:
|
|
332
|
+
continue
|
|
333
|
+
shape = var.shape
|
|
334
|
+
if len(shape) != 1 or shape[0] != n_depth:
|
|
335
|
+
continue
|
|
336
|
+
# Skip CF coordinate variables (e.g. ``probe`` on the ``probe``
|
|
337
|
+
# dim) — netCDF4 lists them alongside data vars, while xarray
|
|
338
|
+
# promotes them to coords. The previous xarray-based path
|
|
339
|
+
# was filtering them implicitly via ``ds.data_vars``.
|
|
340
|
+
if len(var.dimensions) == 1 and var.dimensions[0] == vname:
|
|
341
|
+
continue
|
|
342
|
+
# Skip time coordinate vars (t, t_slow) by CF units.
|
|
343
|
+
try:
|
|
344
|
+
units = var.getncattr("units")
|
|
345
|
+
except (AttributeError, KeyError):
|
|
346
|
+
units = ""
|
|
347
|
+
if isinstance(units, str) and " since " in units:
|
|
348
|
+
continue
|
|
349
|
+
# Skip non-numeric variables (string-typed labels, etc.).
|
|
350
|
+
if var.dtype.kind not in ("f", "i", "u"):
|
|
351
|
+
continue
|
|
352
|
+
data[str(vname)] = _decoded(var)
|
|
353
|
+
preserved = {a: getattr(var, a) for a in var.ncattrs()
|
|
354
|
+
if a in _PRESERVE_DATAVAR_ATTRS}
|
|
355
|
+
if preserved:
|
|
356
|
+
var_attrs[str(vname)] = preserved
|
|
357
|
+
finally:
|
|
358
|
+
ds.close()
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
"depth": depth,
|
|
362
|
+
"vars": data,
|
|
363
|
+
"scalars": scalars,
|
|
364
|
+
"scalar_attrs": scalar_attrs,
|
|
365
|
+
"var_attrs": var_attrs,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _bin_snapshot(snapshot: dict, bin_edges: np.ndarray, agg, diagnostics: bool) -> dict:
|
|
370
|
+
"""Bin a pre-loaded snapshot onto *bin_edges* — no file IO.
|
|
371
|
+
|
|
372
|
+
Returns the same shape of dict that the previous ``_bin_one_profile``
|
|
373
|
+
produced (``vars``, ``stds``, ``scalars``, ``scalar_attrs``).
|
|
374
|
+
"""
|
|
375
|
+
out: dict = {
|
|
376
|
+
"vars": {},
|
|
377
|
+
"stds": {},
|
|
378
|
+
"finite_counts": {},
|
|
379
|
+
"counts": None,
|
|
380
|
+
"scalars": snapshot["scalars"],
|
|
381
|
+
"scalar_attrs": snapshot["scalar_attrs"],
|
|
382
|
+
"var_attrs": snapshot.get("var_attrs", {}),
|
|
383
|
+
}
|
|
384
|
+
depth = snapshot["depth"]
|
|
385
|
+
for vname, arr in snapshot["vars"].items():
|
|
386
|
+
if arr.dtype.kind == "u":
|
|
387
|
+
# QC flag bitfields (uint dtype): OR-pool, never average.
|
|
388
|
+
out["vars"][vname] = _bin_or(arr, depth, bin_edges)
|
|
389
|
+
if out["counts"] is None:
|
|
390
|
+
_, counts = _bin_array(arr.astype(np.float64), depth, bin_edges, agg)
|
|
391
|
+
out["counts"] = counts
|
|
392
|
+
continue
|
|
393
|
+
if vname.endswith("LnSigma"):
|
|
394
|
+
# Standard deviations combine in quadrature: average the variances
|
|
395
|
+
# (RMS of sigma), not the sigmas. Use np.nanmean explicitly, NOT
|
|
396
|
+
# the dataset's `agg`: sqrt(mean(sigma^2)) is the RMS, but
|
|
397
|
+
# sqrt(median(sigma^2)) is not -- under median binning that
|
|
398
|
+
# square/root pair collapses to ~median(|sigma|) and silently
|
|
399
|
+
# mis-states the combined log-uncertainty.
|
|
400
|
+
binned_var, counts = _bin_array(
|
|
401
|
+
arr.astype(np.float64) ** 2, depth, bin_edges, np.nanmean
|
|
402
|
+
)
|
|
403
|
+
out["vars"][vname] = np.sqrt(binned_var)
|
|
404
|
+
else:
|
|
405
|
+
binned, counts = _bin_array(arr, depth, bin_edges, agg)
|
|
406
|
+
out["vars"][vname] = binned
|
|
407
|
+
if out["counts"] is None:
|
|
408
|
+
out["counts"] = counts
|
|
409
|
+
if diagnostics:
|
|
410
|
+
out["stds"][vname] = _bin_std(arr, depth, bin_edges)
|
|
411
|
+
out["finite_counts"][vname] = _bin_finite_counts(arr, depth, bin_edges)
|
|
412
|
+
return out
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _bin_one_profile(
|
|
416
|
+
profile_file: Path,
|
|
417
|
+
bin_edges: np.ndarray,
|
|
418
|
+
agg,
|
|
419
|
+
diagnostics: bool,
|
|
420
|
+
) -> dict:
|
|
421
|
+
"""Open + bin a single profile NetCDF onto *bin_edges*.
|
|
422
|
+
|
|
423
|
+
Compatibility shim around :func:`_load_profile_snapshot` +
|
|
424
|
+
:func:`_bin_snapshot` for callers that want the old "one open, one
|
|
425
|
+
bin" behavior in a single function call.
|
|
426
|
+
"""
|
|
427
|
+
snap = _load_profile_snapshot(profile_file)
|
|
428
|
+
if snap is None:
|
|
429
|
+
return {"vars": {}, "stds": {}, "scalars": {}, "scalar_attrs": {}, "var_attrs": {}}
|
|
430
|
+
return _bin_snapshot(snap, bin_edges, agg, diagnostics)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# Top-level worker entrypoints — must be importable for spawn-style
|
|
434
|
+
# multiprocessing (macOS default). The thin tuple signatures keep the
|
|
435
|
+
# Python pickle small: just file path + small numpy array + tiny config.
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _bin_scan_worker(profile_file: Path) -> tuple[float, float] | None:
|
|
439
|
+
"""Worker: read just enough of the file to report (min, max) depth."""
|
|
440
|
+
snap = _load_profile_snapshot(profile_file)
|
|
441
|
+
if snap is None:
|
|
442
|
+
return None
|
|
443
|
+
d = snap["depth"]
|
|
444
|
+
d = d[np.isfinite(d)]
|
|
445
|
+
if d.size == 0:
|
|
446
|
+
return None
|
|
447
|
+
return float(d.min()), float(d.max())
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _bin_compute_worker(args: tuple) -> dict:
|
|
451
|
+
"""Worker: load snapshot + bin onto pre-computed *bin_edges*."""
|
|
452
|
+
profile_file, bin_edges, aggregation, diagnostics = args
|
|
453
|
+
return _bin_one_profile(profile_file, bin_edges, _get_agg_func(aggregation), diagnostics)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def bin_by_depth(
|
|
457
|
+
profile_files: list[Path],
|
|
458
|
+
bin_width: float = 1.0,
|
|
459
|
+
aggregation: str = "mean",
|
|
460
|
+
diagnostics: bool = False,
|
|
461
|
+
log_dir: Path | None = None,
|
|
462
|
+
jobs: int = 1,
|
|
463
|
+
) -> xr.Dataset:
|
|
464
|
+
"""Bin per-profile NetCDFs by depth into 2D (bin x profile).
|
|
465
|
+
|
|
466
|
+
Parameters
|
|
467
|
+
----------
|
|
468
|
+
profile_files : list of Path
|
|
469
|
+
Paths to per-profile NetCDF files.
|
|
470
|
+
bin_width : float
|
|
471
|
+
Depth bin width [m].
|
|
472
|
+
aggregation : str
|
|
473
|
+
"mean" or "median".
|
|
474
|
+
diagnostics : bool
|
|
475
|
+
Include n_samples and *_std per bin.
|
|
476
|
+
log_dir : Path, optional
|
|
477
|
+
When supplied, the per-input-file binning loop is wrapped in
|
|
478
|
+
:func:`~odas_tpw.perturb.logging_setup.stage_log` so each source
|
|
479
|
+
file's records land in ``<log_dir>/<source_stem>.log`` (in
|
|
480
|
+
addition to the worker/run logs).
|
|
481
|
+
jobs : int, default 1
|
|
482
|
+
When > 1, dispatch the per-profile depth scan and the per-profile
|
|
483
|
+
load+bin onto a process pool of *jobs* workers. The two phases
|
|
484
|
+
share one ``ProcessPoolExecutor`` so spawn-fork overhead is paid
|
|
485
|
+
once.
|
|
486
|
+
|
|
487
|
+
Returns
|
|
488
|
+
-------
|
|
489
|
+
xr.Dataset with dims (bin, profile).
|
|
490
|
+
|
|
491
|
+
Notes
|
|
492
|
+
-----
|
|
493
|
+
Serial path (jobs <= 1): each file is read once into an in-memory
|
|
494
|
+
snapshot, the global bin grid is computed from cached depths, then
|
|
495
|
+
each snapshot is binned without further IO — single open per file.
|
|
496
|
+
|
|
497
|
+
Parallel path (jobs > 1): workers each open their share of files
|
|
498
|
+
twice (once for the scan, once for the bin); we accept that second
|
|
499
|
+
open in exchange for shipping just (min,max) tuples through pickle
|
|
500
|
+
in phase 1 instead of the full snapshot dicts. For SN465-class
|
|
501
|
+
workloads this is dominated by phase 2 (load + bin) which scales
|
|
502
|
+
cleanly with worker count.
|
|
503
|
+
"""
|
|
504
|
+
agg = _get_agg_func(aggregation)
|
|
505
|
+
n_profiles = len(profile_files)
|
|
506
|
+
|
|
507
|
+
# ---- Phase 1: scan depth ranges -------------------------------------
|
|
508
|
+
if jobs > 1 and n_profiles > 1:
|
|
509
|
+
with ProcessPoolExecutor(max_workers=jobs) as exe:
|
|
510
|
+
ranges = list(exe.map(_bin_scan_worker, profile_files))
|
|
511
|
+
else:
|
|
512
|
+
ranges = [_bin_scan_worker(f) for f in profile_files]
|
|
513
|
+
|
|
514
|
+
g_min = np.inf
|
|
515
|
+
g_max = -np.inf
|
|
516
|
+
saw_any = False
|
|
517
|
+
for r in ranges:
|
|
518
|
+
if r is None:
|
|
519
|
+
continue
|
|
520
|
+
saw_any = True
|
|
521
|
+
g_min = min(g_min, r[0])
|
|
522
|
+
g_max = max(g_max, r[1])
|
|
523
|
+
if not saw_any:
|
|
524
|
+
return xr.Dataset()
|
|
525
|
+
|
|
526
|
+
d_min = np.floor(g_min / bin_width) * bin_width
|
|
527
|
+
d_max = np.ceil(g_max / bin_width) * bin_width
|
|
528
|
+
# Defect fix: build edges from an integer bin count instead of
|
|
529
|
+
# np.arange(d_min, d_max + bin_width, bin_width). For non-binary-exact
|
|
530
|
+
# bin_width (e.g. 0.7) np.arange's float accumulation can land the
|
|
531
|
+
# penultimate edge just below the stop and emit a spurious extra trailing
|
|
532
|
+
# bin past d_max, stealing the deepest boundary sample (round-then-arange).
|
|
533
|
+
n_edges = round(float((d_max - d_min) / bin_width)) + 1
|
|
534
|
+
bin_edges = d_min + np.arange(n_edges) * bin_width
|
|
535
|
+
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0
|
|
536
|
+
n_bins = len(bin_centers)
|
|
537
|
+
|
|
538
|
+
# ---- Phase 2: load + bin --------------------------------------------
|
|
539
|
+
if jobs > 1 and n_profiles > 1:
|
|
540
|
+
args_iter = ((f, bin_edges, aggregation, diagnostics) for f in profile_files)
|
|
541
|
+
with ProcessPoolExecutor(max_workers=jobs) as exe:
|
|
542
|
+
results = list(exe.map(_bin_compute_worker, args_iter))
|
|
543
|
+
else:
|
|
544
|
+
results = [_bin_one_profile(f, bin_edges, agg, diagnostics) for f in profile_files]
|
|
545
|
+
|
|
546
|
+
result_vars: dict = {}
|
|
547
|
+
profile_scalars = {
|
|
548
|
+
"lat": np.full(n_profiles, np.nan),
|
|
549
|
+
"lon": np.full(n_profiles, np.nan),
|
|
550
|
+
"stime": np.full(n_profiles, np.nan),
|
|
551
|
+
"etime": np.full(n_profiles, np.nan),
|
|
552
|
+
}
|
|
553
|
+
profile_scalar_attrs: dict[str, dict] = {}
|
|
554
|
+
# Curated data-var attrs (e.g. the FP07 in-situ calibration tag) from the
|
|
555
|
+
# first profile that carries them; concat keeps the first anyway.
|
|
556
|
+
profile_var_attrs: dict[str, dict] = {}
|
|
557
|
+
|
|
558
|
+
for pi, (pfile, res) in enumerate(zip(profile_files, results)):
|
|
559
|
+
with stage_log(log_dir, _source_stem(pfile)):
|
|
560
|
+
if not res["vars"] and not res["scalars"]:
|
|
561
|
+
continue
|
|
562
|
+
for sname, val in res["scalars"].items():
|
|
563
|
+
profile_scalars[sname][pi] = val
|
|
564
|
+
if sname not in profile_scalar_attrs:
|
|
565
|
+
profile_scalar_attrs[sname] = res["scalar_attrs"].get(sname, {})
|
|
566
|
+
for vname, binned in res["vars"].items():
|
|
567
|
+
if vname not in profile_var_attrs and res.get("var_attrs", {}).get(vname):
|
|
568
|
+
profile_var_attrs[vname] = res["var_attrs"][vname]
|
|
569
|
+
if vname not in result_vars:
|
|
570
|
+
result_vars[vname] = np.full((n_bins, n_profiles), np.nan)
|
|
571
|
+
if diagnostics:
|
|
572
|
+
result_vars[f"{vname}_std"] = np.full(
|
|
573
|
+
(n_bins, n_profiles), np.nan
|
|
574
|
+
)
|
|
575
|
+
result_vars[f"{vname}_n"] = np.full(
|
|
576
|
+
(n_bins, n_profiles), np.nan
|
|
577
|
+
)
|
|
578
|
+
result_vars[vname][:, pi] = binned
|
|
579
|
+
if diagnostics:
|
|
580
|
+
for vname, std_arr in res["stds"].items():
|
|
581
|
+
result_vars[f"{vname}_std"][:, pi] = std_arr
|
|
582
|
+
for vname, n_arr in res.get("finite_counts", {}).items():
|
|
583
|
+
key = f"{vname}_n"
|
|
584
|
+
if key in result_vars:
|
|
585
|
+
result_vars[key][:, pi] = n_arr
|
|
586
|
+
|
|
587
|
+
if diagnostics:
|
|
588
|
+
counts = np.zeros((n_bins, n_profiles), dtype=float)
|
|
589
|
+
for pi, res in enumerate(results):
|
|
590
|
+
if res.get("counts") is not None:
|
|
591
|
+
counts[:, pi] = res["counts"]
|
|
592
|
+
result_vars["n_samples"] = counts
|
|
593
|
+
|
|
594
|
+
data_vars: dict = {}
|
|
595
|
+
for vname, arr in result_vars.items():
|
|
596
|
+
attrs = profile_var_attrs.get(vname, {})
|
|
597
|
+
data_vars[str(vname)] = (
|
|
598
|
+
(["bin", "profile"], arr, attrs) if attrs else (["bin", "profile"], arr)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Emit per-profile scalars as 1-D vars on the profile dim, only when at
|
|
602
|
+
# least one profile actually carried them (preserves backward
|
|
603
|
+
# compatibility with older per-profile NCs that lack lat/lon/stime/etime).
|
|
604
|
+
for sname, sarr in profile_scalars.items():
|
|
605
|
+
if np.any(np.isfinite(sarr)):
|
|
606
|
+
data_vars[sname] = (["profile"], sarr, profile_scalar_attrs.get(sname, {}))
|
|
607
|
+
|
|
608
|
+
return xr.Dataset(
|
|
609
|
+
data_vars,
|
|
610
|
+
coords={
|
|
611
|
+
"bin": bin_centers,
|
|
612
|
+
"profile": np.arange(n_profiles),
|
|
613
|
+
},
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def bin_by_time(
|
|
618
|
+
profile_files: list[Path],
|
|
619
|
+
bin_width: float = 1.0,
|
|
620
|
+
aggregation: str = "mean",
|
|
621
|
+
diagnostics: bool = False,
|
|
622
|
+
log_dir: Path | None = None,
|
|
623
|
+
) -> xr.Dataset:
|
|
624
|
+
"""Bin per-profile NetCDFs by time.
|
|
625
|
+
|
|
626
|
+
Returns a concatenated dataset along a time dimension.
|
|
627
|
+
|
|
628
|
+
See :func:`bin_by_depth` for the meaning of *log_dir*.
|
|
629
|
+
|
|
630
|
+
.. note::
|
|
631
|
+
Unlike :func:`bin_by_depth`, the *diagnostics* outputs
|
|
632
|
+
(``*_std`` / ``*_n`` / ``n_samples``) are not implemented for time
|
|
633
|
+
binning; only the binned means are written. A one-time warning is
|
|
634
|
+
emitted when *diagnostics* is requested so the absence is not silent.
|
|
635
|
+
"""
|
|
636
|
+
agg = _get_agg_func(aggregation)
|
|
637
|
+
if diagnostics:
|
|
638
|
+
# Time binning does not (yet) emit the per-bin std / finite-count /
|
|
639
|
+
# n_samples diagnostics that the depth path does. Warn once rather than
|
|
640
|
+
# silently honoring the flag with mean-only output (#40/#53).
|
|
641
|
+
logger.warning(
|
|
642
|
+
"bin_by_time: diagnostics outputs (*_std, *_n, n_samples) are not "
|
|
643
|
+
"produced for time binning; only the binned means are written"
|
|
644
|
+
)
|
|
645
|
+
all_binned = []
|
|
646
|
+
|
|
647
|
+
for f in profile_files:
|
|
648
|
+
with stage_log(log_dir, _source_stem(f)):
|
|
649
|
+
with xr.open_dataset(f) as ds:
|
|
650
|
+
# Use the time coordinate
|
|
651
|
+
for time_name in ("t_slow", "t_fast", "time"):
|
|
652
|
+
if time_name in ds:
|
|
653
|
+
t_raw = ds[time_name].values
|
|
654
|
+
break
|
|
655
|
+
else:
|
|
656
|
+
if diagnostics:
|
|
657
|
+
logger.warning(
|
|
658
|
+
"bin_by_time: %s has no time coordinate "
|
|
659
|
+
"(t_slow/t_fast/time); dropping it from the binned output",
|
|
660
|
+
f.name,
|
|
661
|
+
)
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
t = _time_to_seconds(t_raw)
|
|
665
|
+
finite_t = np.isfinite(t)
|
|
666
|
+
if not finite_t.any():
|
|
667
|
+
if diagnostics:
|
|
668
|
+
logger.warning(
|
|
669
|
+
"bin_by_time: %s has no finite times; dropping it from "
|
|
670
|
+
"the binned output",
|
|
671
|
+
f.name,
|
|
672
|
+
)
|
|
673
|
+
continue
|
|
674
|
+
t_min = float(np.nanmin(t[finite_t]))
|
|
675
|
+
t_max = float(np.nanmax(t[finite_t]))
|
|
676
|
+
# Integer-first edge count via CEIL (not round): avoids the
|
|
677
|
+
# np.arange float-stop spurious trailing bin while still
|
|
678
|
+
# guaranteeing the last edge >= t_max, so the final partial bin's
|
|
679
|
+
# samples are never silently dropped (round() truncated the span
|
|
680
|
+
# when its fractional part was < 0.5).
|
|
681
|
+
n_edges = int(np.ceil((t_max - t_min) / bin_width)) + 1
|
|
682
|
+
bin_edges = t_min + np.arange(n_edges) * bin_width
|
|
683
|
+
if len(bin_edges) < 2:
|
|
684
|
+
bin_edges = np.array([t_min, t_min + bin_width])
|
|
685
|
+
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0
|
|
686
|
+
|
|
687
|
+
binned_data = {"time": bin_centers}
|
|
688
|
+
for vname in ds.data_vars:
|
|
689
|
+
arr = ds[vname].values
|
|
690
|
+
if arr.ndim != 1 or len(arr) != len(t):
|
|
691
|
+
continue
|
|
692
|
+
if arr.dtype.kind == "M": # skip datetime64 variables
|
|
693
|
+
continue
|
|
694
|
+
if arr.dtype.kind not in ("f", "i", "u"):
|
|
695
|
+
continue
|
|
696
|
+
# Match the depth-binning dispatch (_bin_snapshot): QC flag
|
|
697
|
+
# bitfields OR-pool, LnSigma combines in quadrature, the rest
|
|
698
|
+
# aggregate normally. Plain nanmean here would average flags
|
|
699
|
+
# into fractional garbage and underestimate the sigmas.
|
|
700
|
+
if arr.dtype.kind == "u":
|
|
701
|
+
binned_data[str(vname)] = _bin_or(arr, t, bin_edges)
|
|
702
|
+
continue
|
|
703
|
+
if str(vname).endswith("LnSigma"):
|
|
704
|
+
# Quadrature RMS requires the arithmetic mean of the
|
|
705
|
+
# variances regardless of the dataset's `agg` (median
|
|
706
|
+
# would break the sqrt(mean(sigma^2)) identity).
|
|
707
|
+
bvar, _ = _bin_array(
|
|
708
|
+
arr.astype(np.float64) ** 2, t, bin_edges, np.nanmean
|
|
709
|
+
)
|
|
710
|
+
binned_data[str(vname)] = np.sqrt(bvar)
|
|
711
|
+
continue
|
|
712
|
+
b, _ = _bin_array(arr, t, bin_edges, agg)
|
|
713
|
+
binned_data[str(vname)] = b
|
|
714
|
+
|
|
715
|
+
if len(binned_data) > 1:
|
|
716
|
+
bds = xr.Dataset(
|
|
717
|
+
{k: (["time"], v) for k, v in binned_data.items() if k != "time"},
|
|
718
|
+
coords={"time": binned_data["time"]},
|
|
719
|
+
)
|
|
720
|
+
all_binned.append(bds)
|
|
721
|
+
|
|
722
|
+
if not all_binned:
|
|
723
|
+
return xr.Dataset()
|
|
724
|
+
return xr.concat(all_binned, dim="time")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def bin_diss(
|
|
728
|
+
diss_files: list[Path],
|
|
729
|
+
bin_width: float = 1.0,
|
|
730
|
+
aggregation: str = "mean",
|
|
731
|
+
method: str = "depth",
|
|
732
|
+
diagnostics: bool = False,
|
|
733
|
+
log_dir: Path | None = None,
|
|
734
|
+
jobs: int = 1,
|
|
735
|
+
) -> xr.Dataset:
|
|
736
|
+
"""Bin dissipation estimates by depth or time.
|
|
737
|
+
|
|
738
|
+
Handles per-probe (e_1, e_2) and combined (epsilonMean) variables.
|
|
739
|
+
"""
|
|
740
|
+
if method == "time":
|
|
741
|
+
return bin_by_time(diss_files, bin_width, aggregation, diagnostics, log_dir=log_dir)
|
|
742
|
+
return bin_by_depth(
|
|
743
|
+
diss_files, bin_width, aggregation, diagnostics, log_dir=log_dir, jobs=jobs
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def bin_chi(
|
|
748
|
+
chi_files: list[Path],
|
|
749
|
+
bin_width: float = 1.0,
|
|
750
|
+
aggregation: str = "mean",
|
|
751
|
+
method: str = "depth",
|
|
752
|
+
diagnostics: bool = False,
|
|
753
|
+
log_dir: Path | None = None,
|
|
754
|
+
jobs: int = 1,
|
|
755
|
+
) -> xr.Dataset:
|
|
756
|
+
"""Bin chi estimates by depth or time."""
|
|
757
|
+
if method == "time":
|
|
758
|
+
return bin_by_time(chi_files, bin_width, aggregation, diagnostics, log_dir=log_dir)
|
|
759
|
+
return bin_by_depth(
|
|
760
|
+
chi_files, bin_width, aggregation, diagnostics, log_dir=log_dir, jobs=jobs
|
|
761
|
+
)
|