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