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,458 @@
|
|
|
1
|
+
# Jul-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Thorpe-scale overturn analysis for microstructure profiles.
|
|
3
|
+
|
|
4
|
+
Density inversions (overturns) in a stratified profile carry the signature
|
|
5
|
+
of active turbulence. Adiabatically re-sorting a profile segment to a
|
|
6
|
+
statically stable ordering yields, for each sample, the *Thorpe
|
|
7
|
+
displacement* — how far the parcel must move to reach its sorted resting
|
|
8
|
+
depth (Thorpe 1977):
|
|
9
|
+
|
|
10
|
+
``delta_T(z_j) = z_j - z_sorted(j)`` [m]
|
|
11
|
+
|
|
12
|
+
The *Thorpe scale* is the rms displacement over the segment,
|
|
13
|
+
``L_T = rms(delta_T)``, a proxy for the overturn (energy-containing) size.
|
|
14
|
+
Two derived quantities connect it to the dissipation-scale measurements:
|
|
15
|
+
|
|
16
|
+
- **Patch stratification** ``N2_patch`` — the overturn-weighted background
|
|
17
|
+
stratification the turbulence is working against (Smyth et al. 2001;
|
|
18
|
+
operational form per Kaminski et al. 2021, their Eq. 10):
|
|
19
|
+
|
|
20
|
+
``N2_patch = coef * rms(x - x_sorted) / L_T``
|
|
21
|
+
|
|
22
|
+
where ``x`` is the sort proxy and ``coef`` converts its rms fluctuation
|
|
23
|
+
to buoyancy: ``alpha*g`` for temperature, ``g/rho_0`` for potential
|
|
24
|
+
density. NOTE: the average must be an **rms** — the plain mean of
|
|
25
|
+
``x_sorted - x`` is identically zero (sorting permutes the same values),
|
|
26
|
+
a trap present in the compact notation of some papers (e.g. Lewin et
|
|
27
|
+
al. 2025, their Eq. 5, whose <.> resolves to Kaminski's rms form). For
|
|
28
|
+
a fully overturned linear profile ``rms(x - x_sorted) = |dx/dz| * L_T``,
|
|
29
|
+
so ``N2_patch`` recovers the true background ``N2`` exactly.
|
|
30
|
+
|
|
31
|
+
- **Ozmidov scale** ``L_O = (epsilon / N2_patch^(3/2))^(1/2)`` — the
|
|
32
|
+
largest vertical scale buoyancy allows turbulence to overturn — and the
|
|
33
|
+
ratio ``R_OT = L_O / L_T``, a proxy for the age/state of a mixing event
|
|
34
|
+
(Dillon 1982; Smyth et al. 2001; Mashayek et al. 2021).
|
|
35
|
+
|
|
36
|
+
Overturn detection is noise-limited: sensor noise produces spurious
|
|
37
|
+
cm-scale inversions, so every window carries Galbraith & Kelley (1996)
|
|
38
|
+
style diagnostics (same-sign displacement run length, displaced fraction)
|
|
39
|
+
and an edge-truncation flag (an overturn clipped by the sort window biases
|
|
40
|
+
``L_T`` low). Callers apply route-dependent ``L_T`` floors — see
|
|
41
|
+
``DEFAULT_LT_FLOOR_TEMPERATURE`` / ``DEFAULT_LT_FLOOR_DENSITY``.
|
|
42
|
+
|
|
43
|
+
References
|
|
44
|
+
----------
|
|
45
|
+
Thorpe, S.A., 1977: Turbulence and mixing in a Scottish loch.
|
|
46
|
+
Phil. Trans. Roy. Soc. London, A286, 125-181.
|
|
47
|
+
https://doi.org/10.1098/rsta.1977.0112
|
|
48
|
+
Dillon, T.M., 1982: Vertical overturns: A comparison of Thorpe and
|
|
49
|
+
Ozmidov length scales. J. Geophys. Res., 87, 9601-9613.
|
|
50
|
+
https://doi.org/10.1029/JC087iC12p09601
|
|
51
|
+
Galbraith, P.S. and D.E. Kelley, 1996: Identifying overturns in CTD
|
|
52
|
+
profiles. J. Atmos. Oceanic Technol., 13, 688-702.
|
|
53
|
+
https://doi.org/10.1175/1520-0426(1996)013<0688:IOICP>2.0.CO;2
|
|
54
|
+
Smyth, W.D., J.N. Moum, and D.R. Caldwell, 2001: The efficiency of mixing
|
|
55
|
+
in turbulent patches: Inferences from direct simulations and
|
|
56
|
+
microstructure observations. J. Phys. Oceanogr., 31, 1969-1992.
|
|
57
|
+
https://doi.org/10.1175/1520-0485(2001)031<1969:TEOMIT>2.0.CO;2
|
|
58
|
+
Kaminski, A.K., E.A. D'Asaro, A.Y. Shcherbina, and R.R. Harcourt, 2021:
|
|
59
|
+
High-resolution observations of the North Pacific transition layer
|
|
60
|
+
from a Lagrangian float. J. Phys. Oceanogr., 51, 3163-3181.
|
|
61
|
+
https://doi.org/10.1175/JPO-D-21-0032.1
|
|
62
|
+
Lewin, S.F., A.K. Kaminski, J.M. McSweeney, and A.F. Waterhouse, 2025:
|
|
63
|
+
Multiscale mixing variability on the inner shelf.
|
|
64
|
+
J. Phys. Oceanogr., 55, 1735-1750.
|
|
65
|
+
https://doi.org/10.1175/JPO-D-25-0012.1
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
from typing import NamedTuple
|
|
71
|
+
|
|
72
|
+
import gsw
|
|
73
|
+
import numpy as np
|
|
74
|
+
import numpy.typing as npt
|
|
75
|
+
|
|
76
|
+
from odas_tpw.processing.mixing import DEFAULT_MIN_DP
|
|
77
|
+
|
|
78
|
+
# Sort-window span [s] matching the 4-s segments of Lewin et al. (2025).
|
|
79
|
+
# Their span caps detectable overturns at ~2.4-3.2 m at typical VMP fall
|
|
80
|
+
# speeds; a narrower window (e.g. a 2-s chi window) halves that cap and
|
|
81
|
+
# biases L_T low / R_OT high for large overturns.
|
|
82
|
+
DEFAULT_SORT_WINDOW = 4.0
|
|
83
|
+
|
|
84
|
+
# L_T floors [m] below which a window's overturn signal is considered
|
|
85
|
+
# unresolved and callers should fall back to the background N2 (Lewin et
|
|
86
|
+
# al. 2025 fall back for L_T < 0.05 m — 18% of their segments).
|
|
87
|
+
# - temperature route: 0.05 m (the paper's floor; FP07-class response).
|
|
88
|
+
# - density route: provisional 0.10 m — sigma0 from an unpumped JAC CT
|
|
89
|
+
# inherits the thermistor response (~0.1 s => ~7 cm at 0.7 m/s) plus
|
|
90
|
+
# residual salinity spiking; tune from the temperature/density L_T
|
|
91
|
+
# comparison for a given campaign before trusting smaller overturns.
|
|
92
|
+
DEFAULT_LT_FLOOR_TEMPERATURE = 0.05
|
|
93
|
+
DEFAULT_LT_FLOOR_DENSITY = 0.10
|
|
94
|
+
|
|
95
|
+
# Minimum samples in a sort window; fewer gives NaN (a handful of points
|
|
96
|
+
# cannot distinguish an overturn from noise).
|
|
97
|
+
DEFAULT_MIN_SAMPLES = 8
|
|
98
|
+
|
|
99
|
+
# max|delta_T| above this fraction of the window span raises the
|
|
100
|
+
# edge-truncation flag: the overturn plausibly extends past the window,
|
|
101
|
+
# so L_T is a lower bound there.
|
|
102
|
+
EDGE_TRUNCATION_FRACTION = 0.4
|
|
103
|
+
|
|
104
|
+
# A displacement at the first/last sample counts as edge truncation only
|
|
105
|
+
# when it exceeds this fraction of the span. Requiring merely *nonzero*
|
|
106
|
+
# boundary displacement flags essentially every real (noisy) window — a
|
|
107
|
+
# one-sample jiggle at the boundary is noise, not a clipped overturn.
|
|
108
|
+
EDGE_BOUNDARY_FRACTION = 0.1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ThorpeDisplacements(NamedTuple):
|
|
112
|
+
"""Depth-ordered displacement decomposition of one profile segment."""
|
|
113
|
+
|
|
114
|
+
z: np.ndarray # depth [m, positive down], sorted increasing
|
|
115
|
+
x: np.ndarray # observed proxy on the depth-ordered grid
|
|
116
|
+
x_sorted: np.ndarray # stable-sorted (statically stable) proxy profile
|
|
117
|
+
delta: np.ndarray # Thorpe displacement z_j - z_sorted(j) [m]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ThorpeStats(NamedTuple):
|
|
121
|
+
"""Per-window overturn statistics (all NaN/0 conventions per field)."""
|
|
122
|
+
|
|
123
|
+
L_T: float # rms Thorpe displacement [m]
|
|
124
|
+
rms_fluct: float # rms proxy fluctuation rms(x - x_sorted)
|
|
125
|
+
frac_displaced: float # fraction of samples with nonzero displacement
|
|
126
|
+
max_run: int # longest same-sign run of nonzero displacements
|
|
127
|
+
edge_truncated: bool # overturn touches window edge / spans too much
|
|
128
|
+
span: float # vertical span of the window [m]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class WindowThorpeResult(NamedTuple):
|
|
132
|
+
"""Arrays of :class:`ThorpeStats` fields, one entry per window."""
|
|
133
|
+
|
|
134
|
+
L_T: np.ndarray
|
|
135
|
+
rms_fluct: np.ndarray
|
|
136
|
+
frac_displaced: np.ndarray
|
|
137
|
+
max_run: np.ndarray # int; 0 where the window is empty/invalid
|
|
138
|
+
edge_truncated: np.ndarray # bool
|
|
139
|
+
span: np.ndarray
|
|
140
|
+
n: np.ndarray # samples used per window (int)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def thorpe_displacements(
|
|
144
|
+
z: npt.ArrayLike,
|
|
145
|
+
x: npt.ArrayLike,
|
|
146
|
+
*,
|
|
147
|
+
increasing_down: bool,
|
|
148
|
+
) -> ThorpeDisplacements:
|
|
149
|
+
"""Thorpe displacements of one segment, sorted to static stability.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
z : array_like
|
|
154
|
+
Sample depths [m, positive down]; any order, must be finite.
|
|
155
|
+
x : array_like
|
|
156
|
+
Sort proxy (potential density, or temperature) per sample; finite.
|
|
157
|
+
increasing_down : bool
|
|
158
|
+
Stable ordering of the proxy: ``True`` for density-like proxies
|
|
159
|
+
(densest deepest), ``False`` for temperature in typical ocean
|
|
160
|
+
stratification (warmest shallowest).
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
ThorpeDisplacements
|
|
165
|
+
Depth-ordered ``z``, observed ``x``, stable-sorted ``x_sorted``,
|
|
166
|
+
and ``delta = z_j - z_sorted(j)`` (positive when the parcel sits
|
|
167
|
+
deeper than its sorted resting depth). All sorts are stable, so
|
|
168
|
+
tied values keep their observed order and produce zero
|
|
169
|
+
displacement — a monotonic profile returns ``delta == 0`` exactly.
|
|
170
|
+
"""
|
|
171
|
+
z_arr = np.asarray(z, dtype=np.float64)
|
|
172
|
+
x_arr = np.asarray(x, dtype=np.float64)
|
|
173
|
+
if z_arr.shape != x_arr.shape or z_arr.ndim != 1:
|
|
174
|
+
raise ValueError("z and x must be 1-D arrays of equal length")
|
|
175
|
+
if z_arr.size < 2:
|
|
176
|
+
raise ValueError("need at least 2 samples to sort a segment")
|
|
177
|
+
if not (np.isfinite(z_arr).all() and np.isfinite(x_arr).all()):
|
|
178
|
+
raise ValueError("z and x must be finite (filter NaN upstream)")
|
|
179
|
+
|
|
180
|
+
order = np.argsort(z_arr, kind="stable")
|
|
181
|
+
z_s = z_arr[order]
|
|
182
|
+
x_z = x_arr[order]
|
|
183
|
+
|
|
184
|
+
# Stable sort of the proxy into its statically stable ordering. For the
|
|
185
|
+
# descending (temperature) case, negating and sorting ascending keeps
|
|
186
|
+
# the stable tie behavior: equal values stay in observed order.
|
|
187
|
+
key = x_z if increasing_down else -x_z
|
|
188
|
+
val_order = np.argsort(key, kind="stable")
|
|
189
|
+
x_sorted = x_z[val_order]
|
|
190
|
+
|
|
191
|
+
# ranks[j]: depth-rank the sample at depth-rank j occupies after sorting.
|
|
192
|
+
ranks = np.empty(z_s.size, dtype=np.intp)
|
|
193
|
+
ranks[val_order] = np.arange(z_s.size)
|
|
194
|
+
delta = z_s - z_s[ranks]
|
|
195
|
+
|
|
196
|
+
return ThorpeDisplacements(z=z_s, x=x_z, x_sorted=x_sorted, delta=delta)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _max_true_run(b: np.ndarray) -> int:
|
|
200
|
+
"""Length of the longest run of consecutive True values."""
|
|
201
|
+
if not b.any():
|
|
202
|
+
return 0
|
|
203
|
+
edges = np.diff(np.concatenate(([False], b, [False])).astype(np.int8))
|
|
204
|
+
starts = np.flatnonzero(edges == 1)
|
|
205
|
+
ends = np.flatnonzero(edges == -1)
|
|
206
|
+
return int((ends - starts).max())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _max_same_sign_run(delta: np.ndarray) -> int:
|
|
210
|
+
"""Longest run of consecutive same-sign nonzero displacements.
|
|
211
|
+
|
|
212
|
+
The Galbraith & Kelley (1996) run-length idea: uncorrelated sensor
|
|
213
|
+
noise produces sign changes every 1-2 samples, while a real overturn
|
|
214
|
+
displaces a contiguous block of parcels the same way. Small values
|
|
215
|
+
(~1-2) mean noise; thresholding is left to the caller.
|
|
216
|
+
"""
|
|
217
|
+
return max(_max_true_run(delta > 0), _max_true_run(delta < 0))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def thorpe_stats(disp: ThorpeDisplacements) -> ThorpeStats:
|
|
221
|
+
"""Summary statistics of one segment's displacement decomposition."""
|
|
222
|
+
delta = disp.delta
|
|
223
|
+
span = float(disp.z[-1] - disp.z[0])
|
|
224
|
+
L_T = float(np.sqrt(np.mean(delta**2)))
|
|
225
|
+
rms_fluct = float(np.sqrt(np.mean((disp.x - disp.x_sorted) ** 2)))
|
|
226
|
+
displaced = delta != 0.0
|
|
227
|
+
frac = float(np.mean(displaced))
|
|
228
|
+
max_run = _max_same_sign_run(delta)
|
|
229
|
+
edge = bool(
|
|
230
|
+
span > 0
|
|
231
|
+
and (
|
|
232
|
+
abs(float(delta[0])) > EDGE_BOUNDARY_FRACTION * span
|
|
233
|
+
or abs(float(delta[-1])) > EDGE_BOUNDARY_FRACTION * span
|
|
234
|
+
or float(np.max(np.abs(delta))) > EDGE_TRUNCATION_FRACTION * span
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return ThorpeStats(
|
|
238
|
+
L_T=L_T,
|
|
239
|
+
rms_fluct=rms_fluct,
|
|
240
|
+
frac_displaced=frac,
|
|
241
|
+
max_run=max_run,
|
|
242
|
+
edge_truncated=edge,
|
|
243
|
+
span=span,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def window_thorpe(
|
|
248
|
+
win_times: npt.ArrayLike,
|
|
249
|
+
win_half_width: float,
|
|
250
|
+
t: npt.ArrayLike,
|
|
251
|
+
P: npt.ArrayLike,
|
|
252
|
+
x: npt.ArrayLike,
|
|
253
|
+
*,
|
|
254
|
+
increasing_down: bool,
|
|
255
|
+
lat: float = 0.0,
|
|
256
|
+
min_samples: int = DEFAULT_MIN_SAMPLES,
|
|
257
|
+
min_dp: float = DEFAULT_MIN_DP,
|
|
258
|
+
) -> WindowThorpeResult:
|
|
259
|
+
"""Per-window Thorpe statistics for a cast (window loop like mixing.py).
|
|
260
|
+
|
|
261
|
+
For each window (center time ± half width) the samples are depth-sorted
|
|
262
|
+
and stable-sorted by the proxy; windows with fewer than *min_samples*
|
|
263
|
+
finite samples or a pressure span below *min_dp* yield NaN rows
|
|
264
|
+
(``max_run`` 0, ``edge_truncated`` False).
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
win_times : array_like, shape (n_win,)
|
|
269
|
+
Window center times [s] (same time base as ``t``) — typically the
|
|
270
|
+
chi/dissipation window centers, with ``win_half_width`` from
|
|
271
|
+
``DEFAULT_SORT_WINDOW`` rather than the (narrower) chi window.
|
|
272
|
+
win_half_width : float
|
|
273
|
+
Half the sort-window duration [s].
|
|
274
|
+
t, P, x : array_like, shape (n,)
|
|
275
|
+
Sample times [s], pressures [dbar], and sort proxy for one cast
|
|
276
|
+
(e.g. slow-grid ``sigma0`` or slow thermistor temperature).
|
|
277
|
+
increasing_down : bool
|
|
278
|
+
See :func:`thorpe_displacements`.
|
|
279
|
+
lat : float
|
|
280
|
+
Latitude for the pressure-to-depth conversion (gsw.z_from_p).
|
|
281
|
+
min_samples, min_dp : int, float
|
|
282
|
+
Validity floors per window.
|
|
283
|
+
|
|
284
|
+
Returns
|
|
285
|
+
-------
|
|
286
|
+
WindowThorpeResult
|
|
287
|
+
Per-window ``L_T`` [m], ``rms_fluct`` (proxy units),
|
|
288
|
+
``frac_displaced``, ``max_run``, ``edge_truncated``, ``span`` [m],
|
|
289
|
+
and ``n`` samples used.
|
|
290
|
+
"""
|
|
291
|
+
win_times = np.asarray(win_times, dtype=np.float64)
|
|
292
|
+
t = np.asarray(t, dtype=np.float64)
|
|
293
|
+
P = np.asarray(P, dtype=np.float64)
|
|
294
|
+
# float64 promotion matters: products store sigma0 as float32 whose
|
|
295
|
+
# ~3e-5 kg/m^3 quantization is near the fluctuation scale of small
|
|
296
|
+
# overturns; keep all arithmetic in double precision.
|
|
297
|
+
x = np.asarray(x, dtype=np.float64)
|
|
298
|
+
n_win = len(win_times)
|
|
299
|
+
|
|
300
|
+
L_T = np.full(n_win, np.nan)
|
|
301
|
+
rms_fluct = np.full(n_win, np.nan)
|
|
302
|
+
frac = np.full(n_win, np.nan)
|
|
303
|
+
max_run = np.zeros(n_win, dtype=np.intp)
|
|
304
|
+
edge = np.zeros(n_win, dtype=bool)
|
|
305
|
+
span = np.full(n_win, np.nan)
|
|
306
|
+
n_used = np.zeros(n_win, dtype=np.intp)
|
|
307
|
+
|
|
308
|
+
depth = -gsw.z_from_p(P, lat)
|
|
309
|
+
|
|
310
|
+
for j, tau in enumerate(win_times):
|
|
311
|
+
sel = np.abs(t - tau) <= win_half_width
|
|
312
|
+
if not np.any(sel):
|
|
313
|
+
continue
|
|
314
|
+
Pw = P[sel]
|
|
315
|
+
zw = depth[sel]
|
|
316
|
+
xw = x[sel]
|
|
317
|
+
good = np.isfinite(Pw) & np.isfinite(zw) & np.isfinite(xw)
|
|
318
|
+
if int(np.sum(good)) < min_samples:
|
|
319
|
+
continue
|
|
320
|
+
Pw, zw, xw = Pw[good], zw[good], xw[good]
|
|
321
|
+
if np.ptp(Pw) < min_dp:
|
|
322
|
+
continue
|
|
323
|
+
disp = thorpe_displacements(zw, xw, increasing_down=increasing_down)
|
|
324
|
+
stats = thorpe_stats(disp)
|
|
325
|
+
L_T[j] = stats.L_T
|
|
326
|
+
rms_fluct[j] = stats.rms_fluct
|
|
327
|
+
frac[j] = stats.frac_displaced
|
|
328
|
+
max_run[j] = stats.max_run
|
|
329
|
+
edge[j] = stats.edge_truncated
|
|
330
|
+
span[j] = stats.span
|
|
331
|
+
n_used[j] = len(zw)
|
|
332
|
+
|
|
333
|
+
return WindowThorpeResult(
|
|
334
|
+
L_T=L_T,
|
|
335
|
+
rms_fluct=rms_fluct,
|
|
336
|
+
frac_displaced=frac,
|
|
337
|
+
max_run=max_run,
|
|
338
|
+
edge_truncated=edge,
|
|
339
|
+
span=span,
|
|
340
|
+
n=n_used,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def patch_n2(
|
|
345
|
+
rms_fluct: npt.ArrayLike,
|
|
346
|
+
L_T: npt.ArrayLike,
|
|
347
|
+
coef: npt.ArrayLike,
|
|
348
|
+
) -> np.ndarray:
|
|
349
|
+
"""Overturn-weighted "patch" stratification [s^-2].
|
|
350
|
+
|
|
351
|
+
``N2_patch = coef * rms(x - x_sorted) / L_T`` (Smyth et al. 2001;
|
|
352
|
+
Kaminski et al. 2021, Eq. 10), where ``coef`` converts the proxy's rms
|
|
353
|
+
fluctuation to buoyancy:
|
|
354
|
+
|
|
355
|
+
- temperature route: ``coef = alpha * g`` (alpha = thermal expansion
|
|
356
|
+
coefficient [1/K], e.g. ``gsw.alpha`` at the window mean state),
|
|
357
|
+
- density route: ``coef = g / rho_0`` with the proxy ``sigma0``
|
|
358
|
+
[kg/m^3] and ``rho_0 = 1000 + mean(sigma0)``.
|
|
359
|
+
|
|
360
|
+
NaN where ``L_T`` is not positive (no resolved overturn: use the
|
|
361
|
+
background N2 instead, per Lewin et al. 2025) or any input is
|
|
362
|
+
non-finite.
|
|
363
|
+
"""
|
|
364
|
+
rms_arr = np.asarray(rms_fluct, dtype=np.float64)
|
|
365
|
+
lt_arr = np.asarray(L_T, dtype=np.float64)
|
|
366
|
+
coef_arr = np.asarray(coef, dtype=np.float64)
|
|
367
|
+
ok = (
|
|
368
|
+
np.isfinite(rms_arr)
|
|
369
|
+
& np.isfinite(lt_arr)
|
|
370
|
+
& np.isfinite(coef_arr)
|
|
371
|
+
& (lt_arr > 0)
|
|
372
|
+
)
|
|
373
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
374
|
+
n2 = np.where(ok, coef_arr * rms_arr / np.where(ok, lt_arr, 1.0), np.nan)
|
|
375
|
+
return n2
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def ozmidov(epsilon: npt.ArrayLike, N2: npt.ArrayLike) -> np.ndarray:
|
|
379
|
+
"""Ozmidov scale ``L_O = (epsilon / N^3)^(1/2)`` [m].
|
|
380
|
+
|
|
381
|
+
NaN where ``epsilon`` or ``N2`` is non-positive or non-finite (the
|
|
382
|
+
scaling assumes active turbulence in stable stratification).
|
|
383
|
+
"""
|
|
384
|
+
eps = np.asarray(epsilon, dtype=np.float64)
|
|
385
|
+
n2 = np.asarray(N2, dtype=np.float64)
|
|
386
|
+
ok = np.isfinite(eps) & np.isfinite(n2) & (eps > 0) & (n2 > 0)
|
|
387
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
388
|
+
lo = np.where(ok, np.sqrt(eps / np.where(ok, n2, 1.0) ** 1.5), np.nan)
|
|
389
|
+
return lo
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def r_ot(L_O: npt.ArrayLike, L_T: npt.ArrayLike) -> np.ndarray:
|
|
393
|
+
"""Ozmidov-to-Thorpe ratio ``R_OT = L_O / L_T`` [1].
|
|
394
|
+
|
|
395
|
+
NaN where ``L_T`` is not positive (no resolved overturn) or ``L_O``
|
|
396
|
+
is non-finite.
|
|
397
|
+
"""
|
|
398
|
+
lo = np.asarray(L_O, dtype=np.float64)
|
|
399
|
+
lt = np.asarray(L_T, dtype=np.float64)
|
|
400
|
+
ok = np.isfinite(lo) & np.isfinite(lt) & (lt > 0)
|
|
401
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
402
|
+
ratio = np.where(ok, lo / np.where(lt > 0, lt, 1.0), np.nan)
|
|
403
|
+
return ratio
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def reynolds_buoyancy(
|
|
407
|
+
epsilon: npt.ArrayLike,
|
|
408
|
+
nu: npt.ArrayLike,
|
|
409
|
+
N2: npt.ArrayLike,
|
|
410
|
+
) -> np.ndarray:
|
|
411
|
+
"""Buoyancy Reynolds number ``Re_b = epsilon / (nu * N2)`` [1].
|
|
412
|
+
|
|
413
|
+
NaN where any input is non-positive or non-finite.
|
|
414
|
+
"""
|
|
415
|
+
eps = np.asarray(epsilon, dtype=np.float64)
|
|
416
|
+
nu_arr = np.asarray(nu, dtype=np.float64)
|
|
417
|
+
n2 = np.asarray(N2, dtype=np.float64)
|
|
418
|
+
ok = (
|
|
419
|
+
np.isfinite(eps)
|
|
420
|
+
& np.isfinite(nu_arr)
|
|
421
|
+
& np.isfinite(n2)
|
|
422
|
+
& (eps > 0)
|
|
423
|
+
& (nu_arr > 0)
|
|
424
|
+
& (n2 > 0)
|
|
425
|
+
)
|
|
426
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
427
|
+
reb = np.where(ok, eps / (nu_arr * n2), np.nan)
|
|
428
|
+
return reb
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cox_number(
|
|
432
|
+
chi: npt.ArrayLike,
|
|
433
|
+
kappa: npt.ArrayLike,
|
|
434
|
+
dTdz: npt.ArrayLike,
|
|
435
|
+
) -> np.ndarray:
|
|
436
|
+
"""Cox number ``C_x = chi / (2 * kappa * dTdz^2)`` [1].
|
|
437
|
+
|
|
438
|
+
The ratio of turbulent to molecular thermal-variance production
|
|
439
|
+
(Osborn & Cox 1972); Lewin et al. (2025) reject segments with
|
|
440
|
+
``C_x <= 50`` as too weak for a reliable chi fit. ``kappa`` is the
|
|
441
|
+
molecular thermal diffusivity (:func:`odas_tpw.scor160.ocean.kappa_T`).
|
|
442
|
+
NaN where ``chi``/``kappa`` is non-positive or ``dTdz`` is zero or any
|
|
443
|
+
input is non-finite.
|
|
444
|
+
"""
|
|
445
|
+
chi_arr = np.asarray(chi, dtype=np.float64)
|
|
446
|
+
kappa_arr = np.asarray(kappa, dtype=np.float64)
|
|
447
|
+
grad = np.asarray(dTdz, dtype=np.float64)
|
|
448
|
+
ok = (
|
|
449
|
+
np.isfinite(chi_arr)
|
|
450
|
+
& np.isfinite(kappa_arr)
|
|
451
|
+
& np.isfinite(grad)
|
|
452
|
+
& (chi_arr > 0)
|
|
453
|
+
& (kappa_arr > 0)
|
|
454
|
+
& (grad != 0)
|
|
455
|
+
)
|
|
456
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
457
|
+
cx = np.where(ok, chi_arr / (2.0 * kappa_arr * grad**2), np.nan)
|
|
458
|
+
return cx
|