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,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
+ )