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,274 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Top trimming — drop initial instabilities from a profile.
3
+
4
+ Caller supplies one or more fast-rate motion proxies as a dict. The
5
+ algorithm bins each by depth, computes per-bin std, and reports the depth
6
+ below which the instrument's motion has settled. The settling depth is
7
+ located as the first bin beneath the *surface-attached* elevated
8
+ (prop-wash) run — the run of elevated bins anchored to the top of the
9
+ search range, bridging quiet lulls up to ``max_gap`` bins. Bridging lulls
10
+ keeps a momentarily quiet near-surface bin from ending the search early;
11
+ anchoring to the surface keeps an isolated *deep* transient from
12
+ over-trimming the quiet band above it. Channels are combined with the
13
+ median (fully robust to one bad channel only for three or more voters; the
14
+ VMP caller feeds two accelerometers, where median == mean).
15
+
16
+ The instrument-specific question of which channels to feed lives in the
17
+ caller. They must reflect the *instrument's* state, not the ocean: on a
18
+ VMP the accelerometers settle at the true prop-wash exit, whereas shear
19
+ probes, inclinometers, and fall rate stay elevated through deep ocean
20
+ turbulence and would over-trim.
21
+
22
+ Reference: Code/trim_top_profiles.m (85 lines)
23
+ """
24
+
25
+ import numpy as np
26
+ import numpy.typing as npt
27
+
28
+
29
+ def _bin_std(values: np.ndarray, depth: np.ndarray, bin_edges: np.ndarray) -> np.ndarray:
30
+ """Compute standard deviation per depth bin (NaN-aware, ddof=0)."""
31
+ n_bins = len(bin_edges) - 1
32
+ # Bin assignment via searchsorted gives the same [edges[i], edges[i+1])
33
+ # half-open bins as the original mask comparison.
34
+ idx = np.searchsorted(bin_edges, depth, side="right") - 1
35
+ in_range = (idx >= 0) & (idx < n_bins) & np.isfinite(values)
36
+ idx_v = idx[in_range]
37
+ vals_v = values[in_range]
38
+ counts = np.bincount(idx_v, minlength=n_bins)
39
+ sums = np.bincount(idx_v, weights=vals_v, minlength=n_bins)
40
+ sums_sq = np.bincount(idx_v, weights=vals_v * vals_v, minlength=n_bins)
41
+ with np.errstate(invalid="ignore", divide="ignore"):
42
+ means = np.where(counts > 0, sums / np.maximum(counts, 1), 0.0)
43
+ var = sums_sq / np.maximum(counts, 1) - means * means
44
+ var = np.maximum(var, 0.0)
45
+ result = np.where(counts > 1, np.sqrt(var), np.nan)
46
+ return result
47
+
48
+
49
+ def _surface_run_end(elevated: np.ndarray, max_gap: int) -> int | None:
50
+ """Index of the deepest bin of the surface-attached elevated run.
51
+
52
+ Prop wash is a *surface-attached* transient: a run of elevated bins that
53
+ begins at (or within ``max_gap`` bins of) the top of the search range and
54
+ extends downward. Quiet lulls of up to ``max_gap`` bins inside the run are
55
+ bridged so a momentarily quiet near-surface bin does not end the search
56
+ early (audit #66). A wider quiet band separates the surface wash from any
57
+ deeper *isolated* elevated bin (a cable snap-load or mid-column turbulence
58
+ patch), which is contamination — not prop wash — and must not drive the
59
+ trim (audit r1-2).
60
+
61
+ ``elevated`` is a boolean array over a channel's valid bins, shallow→deep.
62
+ Returns the index (into ``elevated``) of the deepest bin in the
63
+ surface-attached run, or None when there is no surface-attached prop wash
64
+ (no elevated bins, or the only elevated bins are detached from the
65
+ surface).
66
+ """
67
+ idx = np.flatnonzero(elevated)
68
+ if idx.size == 0:
69
+ return None
70
+ first = int(idx[0])
71
+ if first > max_gap:
72
+ # Elevated bins exist, but none within max_gap of the surface: the
73
+ # descent was already settled at the top, so these are detached
74
+ # mid-column transients, not prop wash.
75
+ return None
76
+ last = first
77
+ gap = 0
78
+ for pos in range(first + 1, len(elevated)):
79
+ if elevated[pos]:
80
+ last = pos
81
+ gap = 0
82
+ else:
83
+ gap += 1
84
+ if gap > max_gap:
85
+ break
86
+ return last
87
+
88
+
89
+ def compute_trim_depth(
90
+ depth_fast: npt.ArrayLike,
91
+ channels: dict[str, np.ndarray],
92
+ *,
93
+ dz: float = 0.5,
94
+ min_depth: float = 1.0,
95
+ max_depth: float = 50.0,
96
+ quantile: float = 0.6,
97
+ noise_factor: float = 2.0,
98
+ max_gap: int = 3,
99
+ ) -> float | None:
100
+ """Compute the trim depth for a single profile.
101
+
102
+ For each channel the per-bin standard deviation is compared against a
103
+ *settled background* level — the ``quantile`` of that channel's per-bin
104
+ std (a robust estimate of the quiet level, valid while the prop wash
105
+ spans less than ``1 - quantile`` of the binned range). A bin is treated
106
+ as still inside the prop wash when its std exceeds ``noise_factor``
107
+ times that background. The channel's prop-wash exit is the first bin
108
+ *below the surface-attached elevated run* — the run of elevated bins
109
+ that starts within ``max_gap`` bins of the top of the search range and
110
+ extends down, bridging quiet lulls of up to ``max_gap`` bins. Bridging
111
+ lulls prevents a momentarily quiet near-surface bin from ending the
112
+ search early (audit #66); anchoring the run to the surface prevents an
113
+ isolated *deep* transient (a cable snap-load or mid-column turbulence
114
+ patch) from over-trimming the entire quiet band above it (audit r1-2).
115
+ A channel whose only elevated bins are detached from the surface sees
116
+ no prop wash and abstains. The profile trim depth is the **median**
117
+ exit across the channels that detected prop wash. Note the median is
118
+ fully robust to one bad channel only for *three or more* voters; the
119
+ production VMP caller feeds exactly two accelerometers (``Ax``/``Ay``),
120
+ for which the median equals the mean, so a disagreeing channel pulls
121
+ the trim halfway and a lone surface-detecting channel sets it outright.
122
+ The surface-attachment rule above (not the median) is what rejects a
123
+ single channel's spurious *deep* transient. A flat / zero-variance
124
+ channel carries no settling information and is dropped before voting.
125
+
126
+ The caller chooses which channels best mark the instrument's settling.
127
+ On VMP data the accelerometers are the right choice: they capture the
128
+ mechanical entry transient. Shear probes, inclinometers, and fall rate
129
+ respond to the *ocean* turbulence the instrument falls through, so their
130
+ per-bin std stays elevated at depth and would over-trim.
131
+
132
+ Parameters
133
+ ----------
134
+ depth_fast : array_like
135
+ Depth (positive downward, fast rate) [m].
136
+ channels : dict
137
+ Fast-rate channel data, name -> 1-D array. Each channel must
138
+ match ``depth_fast`` in length. Use instrument-motion proxies that
139
+ settle once the descent stabilizes (accelerometers); avoid channels
140
+ driven by ocean turbulence (shear).
141
+ dz : float
142
+ Depth bin size [m].
143
+ min_depth : float
144
+ Minimum search depth [m].
145
+ max_depth : float
146
+ Maximum search depth [m].
147
+ quantile : float
148
+ Quantile of per-bin std taken as the settled background level;
149
+ must be in ``(0, 1)``. Trimming is robust while the prop wash
150
+ spans less than ``1 - quantile`` of the search range.
151
+ noise_factor : float
152
+ A bin counts as still in the prop wash when its std exceeds
153
+ ``noise_factor`` times the settled background. Must be > 1 so the
154
+ background's own bin-to-bin scatter is not mistaken for prop wash.
155
+ max_gap : int
156
+ Maximum run of quiet bins bridged within the surface-attached
157
+ prop-wash run, and the maximum offset of the first elevated bin
158
+ from the surface for the run to count as surface-attached [bins].
159
+ Must be >= 0; ``>= 1`` preserves the audit-#66 momentary-lull
160
+ tolerance. At the default ``dz=0.5`` m, ``max_gap=3`` bridges lulls
161
+ up to ~1.5 m.
162
+
163
+ Returns
164
+ -------
165
+ float or None
166
+ Trim depth [m], or None if no trim point found.
167
+
168
+ Raises
169
+ ------
170
+ ValueError
171
+ If ``quantile`` is not in ``(0, 1)`` or ``noise_factor`` is not
172
+ greater than 1.
173
+ """
174
+ if not 0.0 < quantile < 1.0:
175
+ raise ValueError(f"quantile must be in (0, 1), got {quantile}")
176
+ if not noise_factor > 1.0:
177
+ raise ValueError(f"noise_factor must be > 1, got {noise_factor}")
178
+ if max_gap < 0:
179
+ raise ValueError(f"max_gap must be >= 0, got {max_gap}")
180
+ depth = np.asarray(depth_fast, dtype=np.float64)
181
+ bin_edges = np.arange(min_depth - dz / 2, max_depth + dz, dz)
182
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0
183
+
184
+ if len(bin_edges) < 2:
185
+ return None
186
+
187
+ # Compute std per bin for each channel that carries a real signal.
188
+ all_stds = []
189
+ for name, data in channels.items():
190
+ data = np.asarray(data, dtype=np.float64)
191
+ if len(data) != len(depth):
192
+ continue
193
+ finite = np.isfinite(data)
194
+ if int(finite.sum()) < 2 or np.ptp(data[finite]) == 0:
195
+ # All-NaN or constant: a dead / non-functional sensor carries no
196
+ # settling information, so it must not vote (it would otherwise drag
197
+ # the median toward a minimal trim).
198
+ continue
199
+ all_stds.append(_bin_std(data, depth, bin_edges))
200
+
201
+ if not all_stds:
202
+ return None
203
+
204
+ # Per channel, locate the prop-wash exit: the bin just below the deepest
205
+ # bin of the surface-attached elevated run. Anchoring the run to the
206
+ # surface (rather than taking the globally deepest elevated bin) bridges a
207
+ # momentary near-surface lull (audit #66) while rejecting isolated deep
208
+ # transients that would otherwise over-trim the whole quiet band above them
209
+ # (audit r1-2).
210
+ exits = []
211
+ any_live = False
212
+ for std_arr in all_stds:
213
+ valid = np.isfinite(std_arr)
214
+ if np.sum(valid) < 3:
215
+ continue
216
+ any_live = True
217
+ valid_pos = np.flatnonzero(valid)
218
+ background = np.nanquantile(std_arr[valid], quantile)
219
+ elevated = std_arr[valid_pos] > noise_factor * background
220
+ run_end = _surface_run_end(elevated, max_gap)
221
+ if run_end is None:
222
+ # No surface-attached prop wash (none, or only detached deep
223
+ # contamination): abstain rather than voting a trim — a shallow
224
+ # vote would pull the median up, a deep vote to a transient would
225
+ # discard valid near-surface data.
226
+ continue
227
+ # Bin just below the deepest surface-run bin. If that is the deepest
228
+ # search bin the profile never settled within range; fall back to it
229
+ # (the most conservative, deepest trim).
230
+ deepest = int(valid_pos[run_end])
231
+ exit_idx = min(deepest + 1, len(bin_centers) - 1)
232
+ exits.append(bin_centers[exit_idx])
233
+
234
+ if exits:
235
+ # Median exit across the channels that detected prop wash: robust to a
236
+ # single misbehaving channel (a `max` lets one bad channel drag the
237
+ # trim arbitrarily deep).
238
+ trim = float(np.median(exits))
239
+ # A never-settled cast (the surface run reaches the deepest populated
240
+ # bin) yields an exit bin center in the empty tail of the search range,
241
+ # deeper than every real sample; the caller's ``P >= trim_depth`` would
242
+ # then be empty and apply NO trim, silently keeping the whole
243
+ # wash-contaminated column. Clamp to the deepest observed in-range
244
+ # sample so the trim is always applicable (the cast is then trimmed to
245
+ # its bottom, i.e. flagged as all-wash, rather than passed untouched).
246
+ in_range = depth[(depth >= min_depth) & (depth <= max_depth) & np.isfinite(depth)]
247
+ if in_range.size:
248
+ trim = min(trim, float(np.max(in_range)))
249
+ return trim
250
+ if any_live:
251
+ # Live channels but none saw prop wash: trim minimally (top of range).
252
+ return float(bin_centers[0])
253
+ return None
254
+
255
+
256
+ def compute_trim_depths(
257
+ profiles_data: list[dict],
258
+ **params,
259
+ ) -> list[float | None]:
260
+ """Compute trim depths for multiple profiles.
261
+
262
+ Parameters
263
+ ----------
264
+ profiles_data : list of dict
265
+ Each dict has keys 'depth_fast' and 'channels' (dict of arrays).
266
+ **params
267
+ Keyword arguments passed to :func:`compute_trim_depth`.
268
+
269
+ Returns
270
+ -------
271
+ list of (float or None)
272
+ Per-profile trim depths.
273
+ """
274
+ return [compute_trim_depth(pd["depth_fast"], pd["channels"], **params) for pd in profiles_data]
@@ -0,0 +1,3 @@
1
+ """pyturb-cli: Jesse-compatible CLI backed by odas_tpw processing."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m odas_tpw.pyturb`` invocation."""
2
+
3
+ from odas_tpw.pyturb.cli import main
4
+
5
+ main()
@@ -0,0 +1,204 @@
1
+ """Compatibility helpers for pyturb-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import xarray as xr
9
+
10
+
11
+ def seconds_to_samples(seconds: float, fs: float) -> int:
12
+ """Convert a time duration to a sample count (made even).
13
+
14
+ Matches Jesse's pyturb: ``n = int(seconds * fs)`` rounded to the nearest
15
+ even number (no power-of-2 rounding).
16
+ """
17
+ n = int(seconds * fs)
18
+ if n < 2:
19
+ return 2
20
+ # Make even
21
+ return n if n % 2 == 0 else n + 1
22
+
23
+
24
+ def compute_window_parameters(
25
+ fft_len_sec: float, diss_len_sec: float, fs: float
26
+ ) -> tuple[int, int, int, int]:
27
+ """Compute (n_fft, n_diss, fft_overlap, diss_overlap) matching Jesse's pyturb.
28
+
29
+ Jesse's algorithm:
30
+ n_fft = int(fft_len * fs), made even
31
+ n_diss = round(diss_len * fs), rounded UP to nearest multiple of n_fft
32
+ fft_overlap = n_fft // 2
33
+ diss_overlap = n_fft // 2
34
+ """
35
+ n_fft = seconds_to_samples(fft_len_sec, fs)
36
+ n_diss_raw = round(diss_len_sec * fs)
37
+ # Round up to nearest multiple of n_fft
38
+ n_diss = n_fft if n_diss_raw <= n_fft else ((n_diss_raw + n_fft - 1) // n_fft) * n_fft
39
+ fft_overlap = n_fft // 2
40
+ diss_overlap = n_fft // 2
41
+ return n_fft, n_diss, fft_overlap, diss_overlap
42
+
43
+
44
+ def check_overwrite(path: Path, overwrite: bool) -> bool:
45
+ """Return True if *path* should be written (does not exist, or overwrite is set)."""
46
+ if not path.exists():
47
+ return True
48
+ return overwrite
49
+
50
+
51
+ def load_auxiliary(
52
+ path: str | Path,
53
+ *,
54
+ lat_var: str = "lat",
55
+ lon_var: str = "lon",
56
+ temp_var: str = "temperature",
57
+ sal_var: str = "salinity",
58
+ dens_var: str = "density",
59
+ ) -> xr.Dataset:
60
+ """Load an auxiliary CTD NetCDF and validate required variables exist."""
61
+ ds = xr.open_dataset(path)
62
+ for name, var in [
63
+ ("lat", lat_var),
64
+ ("lon", lon_var),
65
+ ("temp", temp_var),
66
+ ("sal", sal_var),
67
+ ("dens", dens_var),
68
+ ]:
69
+ if var not in ds:
70
+ raise KeyError(f"Auxiliary file missing variable '{var}' (for {name})")
71
+ return ds
72
+
73
+
74
+ def rename_eps_dataset(
75
+ l4,
76
+ l3,
77
+ l3_chi,
78
+ *,
79
+ aux_data: xr.Dataset | None = None,
80
+ fs_fast: float = 512.0,
81
+ salinity: float = 35.0,
82
+ ) -> xr.Dataset:
83
+ """Build an output Dataset with Jesse's variable naming convention.
84
+
85
+ Parameters
86
+ ----------
87
+ l4 : L4Data
88
+ Level-4 dissipation estimates.
89
+ l3 : L3Data
90
+ Level-3 shear wavenumber spectra.
91
+ l3_chi : L3ChiData or None
92
+ Level-3 temperature gradient spectra (may be None).
93
+ aux_data : xr.Dataset, optional
94
+ Auxiliary CTD data to merge.
95
+ fs_fast : float
96
+ Fast sampling rate for metadata.
97
+ salinity : float
98
+ Default salinity for viscosity computation.
99
+ """
100
+ from odas_tpw.scor160.ocean import density as calc_density
101
+ from odas_tpw.scor160.ocean import visc35
102
+
103
+ n_spec = l4.n_spectra
104
+ if n_spec == 0:
105
+ return xr.Dataset()
106
+
107
+ # Scalar arrays
108
+ pres = l4.pres
109
+ speed = l4.pspd_rel
110
+ temp = l3.temp
111
+
112
+ # Viscosity and density
113
+ nu = np.array([visc35(t) for t in temp])
114
+ sal = np.full(n_spec, salinity)
115
+ dens = np.array([calc_density(temp[i], sal[i], pres[i]) for i in range(n_spec)])
116
+
117
+ # Frequency and wavenumber vectors (from first window)
118
+ n_freq = l3.kcyc.shape[0]
119
+ freq = np.arange(n_freq) * fs_fast / ((n_freq - 1) * 2)
120
+ # Mean wavenumber across windows for coordinate
121
+ k_mean = np.mean(l3.kcyc, axis=1)
122
+
123
+ data_vars: dict = {
124
+ "pressure": (["time"], pres, {"units": "dbar", "long_name": "mean pressure"}),
125
+ "W": (["time"], speed, {"units": "m/s", "long_name": "profiling speed"}),
126
+ "temperature": (
127
+ ["time"], temp, {"units": "degree_Celsius", "long_name": "mean temperature"},
128
+ ),
129
+ "nu": (["time"], nu, {"units": "m2/s", "long_name": "kinematic viscosity"}),
130
+ "salinity": (["time"], sal, {"units": "PSU", "long_name": "practical salinity"}),
131
+ "density": (["time"], dens, {"units": "kg/m3", "long_name": "seawater density"}),
132
+ }
133
+
134
+ # Epsilon per probe
135
+ for i in range(l4.n_shear):
136
+ data_vars[f"eps_{i + 1}"] = (
137
+ ["time"],
138
+ l4.epsi[i],
139
+ {"units": "W/kg", "long_name": f"TKE dissipation rate (probe {i + 1})"},
140
+ )
141
+ data_vars[f"k_max_{i + 1}"] = (
142
+ ["time"],
143
+ l4.kmax[i],
144
+ {"units": "cpm", "long_name": f"upper integration limit (probe {i + 1})"},
145
+ )
146
+ data_vars[f"fom_{i + 1}"] = (
147
+ ["time"],
148
+ l4.fom[i],
149
+ {"long_name": f"figure of merit (probe {i + 1})"},
150
+ )
151
+ data_vars[f"mad_{i + 1}"] = (
152
+ ["time"],
153
+ l4.mad[i],
154
+ {"long_name": f"mean absolute deviation (probe {i + 1})"},
155
+ )
156
+ data_vars[f"method_{i + 1}"] = (
157
+ ["time"],
158
+ l4.method[i],
159
+ {"long_name": f"estimation method (probe {i + 1}): 0=variance, 1=ISR"},
160
+ )
161
+
162
+ data_vars["eps_final"] = (
163
+ ["time"],
164
+ l4.epsi_final,
165
+ {"units": "W/kg", "long_name": "combined TKE dissipation rate"},
166
+ )
167
+
168
+ # Shear spectra per probe
169
+ for i in range(l3.n_shear):
170
+ data_vars[f"S_sh{i + 1}"] = (
171
+ ["frequency", "time"],
172
+ l3.sh_spec_clean[i],
173
+ {
174
+ "units": "s-2/cpm",
175
+ "long_name": (
176
+ f"shear spectrum (probe {i + 1}); Goodman-cleaned when "
177
+ "--goodman is set, otherwise the raw spectrum"
178
+ ),
179
+ },
180
+ )
181
+
182
+ # Temperature gradient spectra
183
+ if l3_chi is not None and l3_chi.n_spectra > 0:
184
+ for i in range(l3_chi.n_gradt):
185
+ data_vars[f"S_gradT{i + 1}"] = (
186
+ ["frequency", "time"],
187
+ l3_chi.gradt_spec[i],
188
+ {"units": "K2/m2/cpm", "long_name": f"gradT spectrum (probe {i + 1})"},
189
+ )
190
+
191
+ coords: dict = {
192
+ "frequency": freq,
193
+ "k": (["frequency"], k_mean, {"units": "cpm", "long_name": "wavenumber"}),
194
+ }
195
+
196
+ ds = xr.Dataset(data_vars, coords=coords)
197
+
198
+ # Merge auxiliary data if provided
199
+ if aux_data is not None:
200
+ for var in aux_data.data_vars:
201
+ if var not in ds:
202
+ ds[var] = aux_data[var]
203
+
204
+ return ds
@@ -0,0 +1,124 @@
1
+ """Peak-finding profile detection (Jesse's algorithm).
2
+
3
+ Uses scipy.signal.find_peaks on smoothed pressure to identify
4
+ up-cast and down-cast segments. Does not modify existing
5
+ rsi/profile.py or scor160/profile.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from scipy.signal import butter, filtfilt, find_peaks
12
+
13
+
14
+ def find_profiles_peaks(
15
+ pressure: np.ndarray,
16
+ fs: float,
17
+ *,
18
+ direction: str = "down",
19
+ min_pressure: float = 0.0,
20
+ peaks_height: float = 25.0,
21
+ peaks_distance: int = 200,
22
+ peaks_prominence: float = 25.0,
23
+ min_speed: float = 0.2,
24
+ smoothing_tau: float = 2.0,
25
+ ) -> list[tuple[int, int]]:
26
+ """Find profiles using scipy peak-finding on smoothed pressure.
27
+
28
+ Parameters
29
+ ----------
30
+ pressure : ndarray
31
+ Pressure time series [dbar] at slow rate.
32
+ fs : float
33
+ Sampling rate of *pressure* [Hz].
34
+ direction : str
35
+ 'down', 'up', or 'both'.
36
+ min_pressure : float
37
+ Minimum pressure for valid profile segments [dbar].
38
+ peaks_height : float
39
+ Minimum peak height for ``find_peaks`` [dbar].
40
+ peaks_distance : int
41
+ Minimum distance between peaks [samples].
42
+ peaks_prominence : float
43
+ Minimum peak prominence [dbar].
44
+ min_speed : float
45
+ Minimum vertical speed to include a sample [m/s].
46
+ smoothing_tau : float
47
+ Time constant for Butterworth low-pass smoothing [s].
48
+
49
+ Returns
50
+ -------
51
+ list of (start_idx, end_idx)
52
+ Start and end indices (inclusive) in slow-rate samples.
53
+ """
54
+ pressure = np.asarray(pressure, dtype=np.float64)
55
+ if len(pressure) < 10:
56
+ return []
57
+
58
+ # Low-pass smooth pressure
59
+ f_c = 0.68 / max(smoothing_tau, 0.1)
60
+ nyquist = fs / 2.0
61
+ if f_c >= nyquist:
62
+ f_c = nyquist * 0.9
63
+ b, a = butter(2, f_c / nyquist)
64
+ p_smooth = filtfilt(b, a, pressure)
65
+
66
+ # Find pressure maxima (bottom of downcasts)
67
+ maxima, _ = find_peaks(
68
+ p_smooth,
69
+ height=peaks_height,
70
+ distance=peaks_distance,
71
+ prominence=peaks_prominence,
72
+ )
73
+
74
+ # Find pressure minima (top of casts) — peaks of inverted pressure
75
+ minima, _ = find_peaks(
76
+ -p_smooth,
77
+ distance=peaks_distance,
78
+ )
79
+
80
+ if len(maxima) == 0:
81
+ return []
82
+
83
+ # Build sorted array of all extrema with type labels
84
+ events: list[tuple[int, str]] = []
85
+ for idx in maxima:
86
+ events.append((int(idx), "max"))
87
+ for idx in minima:
88
+ events.append((int(idx), "min"))
89
+ events.sort(key=lambda x: x[0])
90
+
91
+ profiles: list[tuple[int, int]] = []
92
+
93
+ if direction in ("down", "both"):
94
+ # Downcasts: from min to max
95
+ for i, (idx, typ) in enumerate(events):
96
+ if typ != "max":
97
+ continue
98
+ # Find preceding minimum
99
+ prev_min = 0
100
+ for j in range(i - 1, -1, -1):
101
+ if events[j][1] == "min":
102
+ prev_min = events[j][0]
103
+ break
104
+ if p_smooth[idx] - p_smooth[prev_min] >= min_pressure:
105
+ profiles.append((prev_min, idx))
106
+
107
+ if direction in ("up", "both"):
108
+ # Upcasts: from max to min
109
+ for i, (idx, typ) in enumerate(events):
110
+ if typ != "max":
111
+ continue
112
+ # Find following minimum
113
+ next_min = len(pressure) - 1
114
+ for j in range(i + 1, len(events)):
115
+ if events[j][1] == "min":
116
+ next_min = events[j][0]
117
+ break
118
+ if p_smooth[idx] - p_smooth[next_min] >= min_pressure:
119
+ profiles.append((idx, next_min))
120
+
121
+ # Sort by start index
122
+ profiles.sort(key=lambda x: x[0])
123
+
124
+ return profiles