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