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,630 @@
1
+ # Jun-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Derived mixing quantities from co-located epsilon and chi estimates.
3
+
4
+ Microstructure profilers measure two dissipation rates — epsilon (TKE,
5
+ from shear probes) and chi (thermal variance, from FP07 thermistors).
6
+ The quantities oceanographers ultimately want are usually the derived
7
+ ones:
8
+
9
+ - ``K_T`` — Osborn-Cox eddy diffusivity of heat,
10
+ ``K_T = chi / (2 * (dT/dz)^2)`` [m^2/s]
11
+ - ``Gamma`` — dimensionless mixing coefficient (flux coefficient),
12
+ ``Gamma = N^2 * chi / (2 * epsilon * (dT/dz)^2)`` (Oakey 1982)
13
+ - ``K_rho`` — Osborn diapycnal diffusivity with a constant mixing
14
+ coefficient, ``K_rho = Gamma_0 * epsilon / N^2`` (Osborn 1980,
15
+ canonical ``Gamma_0 = 0.2``)
16
+
17
+ Note the algebraic identity ``Gamma * epsilon / N^2 == K_T``: when the
18
+ mixing coefficient is *measured* via Oakey's method, the Osborn
19
+ diffusivity computed with it reproduces the Osborn-Cox diffusivity by
20
+ construction. ``K_rho`` here therefore uses the canonical constant
21
+ ``Gamma_0`` so that comparing ``K_rho`` with ``K_T`` (equivalently,
22
+ ``Gamma`` with ``Gamma_0``) is a physically meaningful consistency
23
+ check, not a tautology.
24
+
25
+ Both ``Gamma`` and the diffusivities divide by background gradients that
26
+ can vanish in weakly stratified water, so estimates are masked where
27
+ ``N^2`` or ``|dT/dz|`` fall below configurable floors.
28
+
29
+ References
30
+ ----------
31
+ Osborn, T.R. and C.S. Cox, 1972: Oceanic fine structure.
32
+ Geophys. Fluid Dyn., 3, 321-345.
33
+ https://doi.org/10.1080/03091927208236085
34
+ Osborn, T.R., 1980: Estimates of the local rate of vertical diffusion
35
+ from dissipation measurements. J. Phys. Oceanogr., 10, 83-89.
36
+ https://doi.org/10.1175/1520-0485(1980)010<0083:EOTLRO>2.0.CO;2
37
+ Oakey, N.S., 1982: Determination of the rate of dissipation of
38
+ turbulent energy from simultaneous temperature and velocity shear
39
+ microstructure measurements. J. Phys. Oceanogr., 12, 256-271.
40
+ https://doi.org/10.1175/1520-0485(1982)012<0256:DOTROD>2.0.CO;2
41
+ Gregg, M.C., E.A. D'Asaro, J.J. Riley, and E. Kunze, 2018: Mixing
42
+ efficiency in the ocean. Annu. Rev. Mar. Sci., 10, 443-473.
43
+ https://doi.org/10.1146/annurev-marine-121916-063643
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import warnings
49
+ from typing import NamedTuple
50
+
51
+ import gsw
52
+ import numpy as np
53
+ import numpy.typing as npt
54
+
55
+ # Stratification floor [s^-2]. Below ~1e-9 s^-2 (N ~ 0.02 cph) the Osborn
56
+ # estimate diverges (division-by-zero guard). A 1e-9 floor prevents the divide
57
+ # but not unphysical inflation: a window whose N^2 sits just above it still
58
+ # yields an absurd K_rho = Gamma_0 * epsilon / N^2. Rather than raise the floor
59
+ # (which would NaN genuinely weakly-stratified bins without benchmark support),
60
+ # K_rho is bounded from above by DEFAULT_K_RHO_MAX below (audit #30).
61
+ DEFAULT_N2_MIN = 1e-9
62
+
63
+ # Upper sanity bound on K_rho [m^2/s] (audit #30). The N2_min floor stops the
64
+ # divide-by-zero but not magnitude inflation: K_rho = Gamma_0 * epsilon / N2 grows
65
+ # without bound as N^2 shrinks toward the floor, so the artifact produces values
66
+ # of tens to thousands of m^2/s. Windows above this bound are masked (set to NaN).
67
+ # 10 m^2/s sits above even the most energetic real diapycnal mixing — overflows
68
+ # and hydraulic jumps reach ~0.1-1 m^2/s, and energetic near-surface mixing on
69
+ # ARCTERX VMP casts was observed up to a few m^2/s — so genuine signal is kept
70
+ # and only physically implausible magnitudes are removed (the unbounded
71
+ # near-floor-N^2 artifact, or extreme contaminated near-surface windows where
72
+ # epsilon is itself spurious). (A 1 m^2/s bound was tried first but clipped real
73
+ # energetic near-surface mixing.) Configurable via K_rho_max.
74
+ DEFAULT_K_RHO_MAX = 10.0
75
+
76
+ # Upper sanity bound on K_T [m^2/s]. K_T = chi/(2*(dT/dz)^2) is a turbulent
77
+ # thermal diffusivity on the same physical scale as K_rho (by the module
78
+ # identity K_T = 5*Gamma*K_rho, so K_T ~ K_rho when Gamma ~ 0.2). It has no N^2
79
+ # in it, so it does NOT blow up on the near-floor-N^2 artifact — but a weak
80
+ # dT/dz combined with a contaminated chi can still push it past any real ocean
81
+ # value, so it gets its OWN ceiling (not K_rho's mask). 10 m^2/s sits above the
82
+ # most energetic real thermal mixing. Configurable via K_T_max.
83
+ DEFAULT_K_T_MAX = 10.0
84
+
85
+ # Upper sanity bound on the measured mixing coefficient Gamma [1]. Gamma is
86
+ # dimensionless with a canonical value ~0.2 and rarely exceeds ~1-2 even in
87
+ # energetic mixing (flux Richardson number Rf < ~0.5 => Gamma = Rf/(1-Rf) < ~1);
88
+ # values of tens to hundreds arise only when a near-zero shear epsilon lands in
89
+ # the denominator (Gamma = N2*chi/(2*epsilon*(dT/dz)^2)). 5.0 keeps all
90
+ # physically plausible mixing efficiency while removing those gross artifacts.
91
+ # Configurable via Gamma_max.
92
+ DEFAULT_GAMMA_MAX = 5.0
93
+
94
+ # Background temperature-gradient floor [K/m]. chi/(dT/dz)^2 diverges
95
+ # as the mean gradient vanishes (well-mixed layers); 1e-4 K/m over a
96
+ # ~5 m window corresponds to ~0.5 mK of signal, near FP07 resolution.
97
+ DEFAULT_DTDZ_MIN = 1e-4
98
+
99
+ # Canonical Osborn (1980) mixing coefficient.
100
+ GAMMA_OSBORN = 0.2
101
+
102
+ # Minimum pressure span [dbar] within a window for gradient estimates;
103
+ # below this (stalled instrument) vertical gradients are unconstrained.
104
+ DEFAULT_MIN_DP = 0.2
105
+
106
+ # Default background vertical window [dbar] for profile/CTD stratification.
107
+ DEFAULT_STRAT_WINDOW = 2.0
108
+
109
+
110
+ class StratificationResult(NamedTuple):
111
+ """Per-window background stratification."""
112
+
113
+ N2: np.ndarray # buoyancy frequency squared [s^-2]
114
+ dTdz: np.ndarray # background conservative-temperature (θ) gradient vs depth [K/m]
115
+
116
+
117
+ class MixingResult(NamedTuple):
118
+ """Per-window derived mixing quantities."""
119
+
120
+ K_T: np.ndarray # Osborn-Cox heat diffusivity [m^2/s]
121
+ Gamma: np.ndarray # measured mixing coefficient [1]
122
+ K_rho: np.ndarray # Osborn diffusivity with Gamma_0 [m^2/s]
123
+
124
+
125
+ def window_stratification(
126
+ win_times: npt.ArrayLike,
127
+ win_half_width: float,
128
+ t: npt.ArrayLike,
129
+ P: npt.ArrayLike,
130
+ T: npt.ArrayLike,
131
+ S: float | npt.ArrayLike | None = None,
132
+ lat: float = 0.0,
133
+ lon: float = 0.0,
134
+ min_samples: int = 4,
135
+ min_dp: float = DEFAULT_MIN_DP,
136
+ ) -> StratificationResult:
137
+ """Background N² and dT/dz for each dissipation window.
138
+
139
+ For each window (center time ± half width):
140
+
141
+ - ``dTdz`` is the slope of a least-squares fit of *conservative*
142
+ temperature (θ, TEOS-10) against depth (positive down), i.e. the
143
+ *background* gradient at the window scale — the same quantity whose
144
+ square normalizes chi in the Osborn-Cox model. Conservative (not
145
+ in-situ) temperature is used so the adiabatic lapse rate, which
146
+ produces no thermal variance, does not enter the gradient.
147
+ - ``N2`` is gsw.Nsquared (TEOS-10) evaluated between the mean
148
+ (SA, CT, p) of the shallow and deep halves of the window, which
149
+ averages out microstructure fluctuations.
150
+
151
+ Parameters
152
+ ----------
153
+ win_times : array_like, shape (n_win,)
154
+ Window center times [s] (same time base as ``t``).
155
+ win_half_width : float
156
+ Half the window duration [s].
157
+ t, P, T : array_like, shape (n,)
158
+ Sample times [s], pressure [dbar], in-situ temperature [degC].
159
+ S : float, array_like, or None
160
+ Practical salinity: scalar, per-sample array, or None.
161
+ None assumes 35 PSU — N² then reflects temperature
162
+ stratification only, which can be badly wrong where salinity
163
+ stratification matters (the integrating callers record which case
164
+ applied in a free-text ``sal_note`` on the output variables).
165
+ lat, lon : float
166
+ Position for TEOS-10 conversions (defaults 0/0 introduce only
167
+ small absolute-salinity-anomaly and gravity errors).
168
+ min_samples : int
169
+ Minimum samples in a window; fewer gives NaN.
170
+ min_dp : float
171
+ Minimum pressure span [dbar] in a window; less gives NaN
172
+ (vertical gradients unconstrained, e.g. stalled instrument).
173
+
174
+ Returns
175
+ -------
176
+ StratificationResult
177
+ ``N2`` [s^-2] and ``dTdz`` [K/m vs depth, positive down] per
178
+ window; NaN where the window has too few samples or span.
179
+ ``N2 > 0`` is stable stratification.
180
+ """
181
+ win_times = np.asarray(win_times, dtype=np.float64)
182
+ t = np.asarray(t, dtype=np.float64)
183
+ P = np.asarray(P, dtype=np.float64)
184
+ T = np.asarray(T, dtype=np.float64)
185
+ n_win = len(win_times)
186
+
187
+ if S is None or np.ndim(S) == 0:
188
+ S_const = 35.0 if S is None else float(S) # type: ignore[arg-type]
189
+ S_arr = None
190
+ else:
191
+ S_const = np.nan
192
+ S_arr = np.asarray(S, dtype=np.float64)
193
+
194
+ N2 = np.full(n_win, np.nan)
195
+ dTdz = np.full(n_win, np.nan)
196
+
197
+ # Depth (positive down) from pressure; lat=0 underestimates gravity
198
+ # by <0.3%, negligible against the gradient-estimation noise.
199
+ depth = -gsw.z_from_p(P, lat)
200
+
201
+ for j, tau in enumerate(win_times):
202
+ sel = np.abs(t - tau) <= win_half_width
203
+ if not np.any(sel):
204
+ continue
205
+ Pw = P[sel]
206
+ Tw = T[sel]
207
+ zw = depth[sel]
208
+ Sw = S_arr[sel] if S_arr is not None else None
209
+
210
+ good = np.isfinite(Pw) & np.isfinite(Tw) & np.isfinite(zw)
211
+ if Sw is not None:
212
+ good &= np.isfinite(Sw)
213
+ if np.sum(good) < min_samples:
214
+ continue
215
+ Pw, Tw, zw = Pw[good], Tw[good], zw[good]
216
+ if Sw is not None:
217
+ Sw = Sw[good]
218
+
219
+ if np.ptp(Pw) < min_dp:
220
+ continue
221
+
222
+ # Background conservative-temperature gradient vs depth (least
223
+ # squares). Osborn-Cox variance production is against the
224
+ # adiabatically-referenced (conservative) temperature gradient; fitting
225
+ # in-situ T folds in the adiabatic lapse rate (~2.4e-4 K/m in warm
226
+ # water), which biases K_T/Gamma and defeats the well-mixed mask
227
+ # (audit r1-1). chi itself is a microstructure quantity and is
228
+ # unaffected — in-situ and conservative temperature *fluctuations*
229
+ # coincide at those scales.
230
+ Sw_vals = Sw if Sw is not None else np.full(len(Pw), S_const)
231
+ SA_w = gsw.SA_from_SP(Sw_vals, Pw, lon, lat)
232
+ CT_w = gsw.CT_from_t(SA_w, Tw, Pw)
233
+ dTdz[j] = np.polyfit(zw, CT_w, 1)[0]
234
+
235
+ # N² between the shallow- and deep-half means (TEOS-10)
236
+ order = np.argsort(Pw)
237
+ half = len(order) // 2
238
+ sh, dp = order[:half], order[half:]
239
+ p_pair = np.array([np.mean(Pw[sh]), np.mean(Pw[dp])])
240
+ t_pair = np.array([np.mean(Tw[sh]), np.mean(Tw[dp])])
241
+ if Sw is not None:
242
+ s_pair = np.array([np.mean(Sw[sh]), np.mean(Sw[dp])])
243
+ else:
244
+ s_pair = np.array([S_const, S_const])
245
+ if p_pair[1] - p_pair[0] < min_dp / 2:
246
+ continue
247
+ SA = gsw.SA_from_SP(s_pair, p_pair, lon, lat)
248
+ CT = gsw.CT_from_t(SA, t_pair, p_pair)
249
+ n2_val, _ = gsw.Nsquared(SA, CT, p_pair, lat)
250
+ N2[j] = float(n2_val[0])
251
+
252
+ return StratificationResult(N2=N2, dTdz=dTdz)
253
+
254
+
255
+ def sorted_stratification(
256
+ win_times: npt.ArrayLike,
257
+ win_half_width: float,
258
+ t: npt.ArrayLike,
259
+ P: npt.ArrayLike,
260
+ T: npt.ArrayLike,
261
+ S: float | npt.ArrayLike | None = None,
262
+ lat: float = 0.0,
263
+ lon: float = 0.0,
264
+ min_samples: int = 4,
265
+ min_dp: float = DEFAULT_MIN_DP,
266
+ ) -> StratificationResult:
267
+ """Background N² and dT/dz per window from the adiabatically sorted profile.
268
+
269
+ Like :func:`window_stratification`, but within each window the parcels are
270
+ Thorpe-sorted to a statically stable ordering (densest deepest) before
271
+ differencing. Sorting removes density inversions / overturns so the
272
+ gradients reflect the *background* stratification rather than the
273
+ instantaneous (possibly unstable) profile — the adiabatic-leveling idea
274
+ applied window-by-window:
275
+
276
+ - The window's (SA, CT, p) are formed via TEOS-10 and ranked by potential
277
+ density (``gsw.sigma0``); the i-th densest parcel is assigned to the
278
+ i-th deepest pressure, giving the stable reference profile.
279
+ - ``N2`` is ``gsw.Nsquared`` between the shallow- and deep-half means of
280
+ that stable profile. After sorting it is ≥ 0 for ordinary stratification,
281
+ but can be slightly negative in strongly density-compensated water:
282
+ ``gsw.Nsquared`` averages SA and CT separately and re-evaluates the
283
+ nonlinear equation of state, which (via cabbeling) is not guaranteed
284
+ monotone under the potential-density (sigma0) sort. Such windows are
285
+ treated as effectively unstratified by the downstream Osborn scaling.
286
+ - ``dTdz`` is the least-squares slope of the stably-sorted
287
+ *conservative* temperature (θ) against depth (positive down); see
288
+ :func:`window_stratification` for why conservative, not in-situ.
289
+
290
+ Parameters and return value match :func:`window_stratification`; NaN is
291
+ returned for windows with fewer than *min_samples* valid samples or a
292
+ pressure span below *min_dp*.
293
+ """
294
+ win_times = np.asarray(win_times, dtype=np.float64)
295
+ t = np.asarray(t, dtype=np.float64)
296
+ P = np.asarray(P, dtype=np.float64)
297
+ T = np.asarray(T, dtype=np.float64)
298
+ n_win = len(win_times)
299
+
300
+ if S is None or np.ndim(S) == 0:
301
+ S_const = 35.0 if S is None else float(S) # type: ignore[arg-type]
302
+ S_arr = None
303
+ else:
304
+ S_const = np.nan
305
+ S_arr = np.asarray(S, dtype=np.float64)
306
+
307
+ N2 = np.full(n_win, np.nan)
308
+ dTdz = np.full(n_win, np.nan)
309
+
310
+ depth = -gsw.z_from_p(P, lat)
311
+
312
+ for j, tau in enumerate(win_times):
313
+ sel = np.abs(t - tau) <= win_half_width
314
+ if not np.any(sel):
315
+ continue
316
+ Pw = P[sel]
317
+ Tw = T[sel]
318
+ zw = depth[sel]
319
+ Sw = S_arr[sel] if S_arr is not None else None
320
+
321
+ good = np.isfinite(Pw) & np.isfinite(Tw) & np.isfinite(zw)
322
+ if Sw is not None:
323
+ good &= np.isfinite(Sw)
324
+ if np.sum(good) < min_samples:
325
+ continue
326
+ Pw, Tw, zw = Pw[good], Tw[good], zw[good]
327
+ Sw = Sw[good] if Sw is not None else None
328
+ if np.ptp(Pw) < min_dp:
329
+ continue
330
+
331
+ Sw_vals = Sw if Sw is not None else np.full(len(Pw), S_const)
332
+ SA = gsw.SA_from_SP(Sw_vals, Pw, lon, lat)
333
+ CT = gsw.CT_from_t(SA, Tw, Pw)
334
+ sigma = gsw.sigma0(SA, CT)
335
+ N2[j], dTdz[j] = _stable_window(Pw, zw, Tw, SA, CT, sigma, lat, min_dp)
336
+
337
+ return StratificationResult(N2=N2, dTdz=dTdz)
338
+
339
+
340
+ def _stable_window(Pw, zw, Tw, SAw, CTw, sigmaw, lat, min_dp):
341
+ """N² and dT/dz for one window's samples, Thorpe-sorted to stability.
342
+
343
+ Reconstructs the statically stable reference profile (the k-th shallowest
344
+ position holds the k-th least-dense parcel), then returns ``(N2, dTdz)``:
345
+ N² from ``gsw.Nsquared`` between the shallow/deep-half means of the stable
346
+ profile (NaN if the half-mean pressure separation is below ``min_dp/2``),
347
+ and dT/dz as the least-squares slope of the stably-sorted *conservative*
348
+ temperature (θ) vs depth. Inputs must be finite with length ≥ 2.
349
+ """
350
+ pos_order = np.argsort(Pw, kind="stable")
351
+ den_order = np.argsort(sigmaw, kind="stable")
352
+ p_stable = Pw[pos_order]
353
+ z_stable = zw[pos_order]
354
+ SA_stable = SAw[den_order]
355
+ CT_stable = CTw[den_order]
356
+
357
+ # Fit the conservative-temperature gradient (not in-situ T): the adiabatic
358
+ # lapse rate is a reversible gradient that produces no thermal variance, so
359
+ # the Osborn-Cox K_T/Gamma denominator must use dCT/dz (audit r1-1).
360
+ dTdz = float(np.polyfit(z_stable, CT_stable, 1)[0])
361
+
362
+ half = len(p_stable) // 2
363
+ p_pair = np.array([np.mean(p_stable[:half]), np.mean(p_stable[half:])])
364
+ if p_pair[1] - p_pair[0] < min_dp / 2:
365
+ return np.nan, dTdz
366
+ sa_pair = np.array([np.mean(SA_stable[:half]), np.mean(SA_stable[half:])])
367
+ ct_pair = np.array([np.mean(CT_stable[:half]), np.mean(CT_stable[half:])])
368
+ n2_val, _ = gsw.Nsquared(sa_pair, ct_pair, p_pair, lat)
369
+ return float(n2_val[0]), dTdz
370
+
371
+
372
+ def profile_stratification(
373
+ P: npt.ArrayLike,
374
+ T: npt.ArrayLike,
375
+ S: float | npt.ArrayLike | None = None,
376
+ lat: float = 0.0,
377
+ lon: float = 0.0,
378
+ window: float = DEFAULT_STRAT_WINDOW,
379
+ min_samples: int = 4,
380
+ min_dp: float = DEFAULT_MIN_DP,
381
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
382
+ """Sorted N²/dT/dz vs pressure for one cast, on a coarse depth grid.
383
+
384
+ The background (profile- and CTD-product) analog of
385
+ :func:`sorted_stratification`: instead of dissipation-window *times*, it
386
+ evaluates at target pressures spaced by ``window/2`` over the cast's
387
+ pressure range. Within each ``±window/2`` pressure window the samples are
388
+ Thorpe-sorted and differenced by the same :func:`_stable_window` method, so
389
+ the two paths report consistent stratification.
390
+
391
+ Evaluating on the coarse target grid (rather than every slow sample) keeps
392
+ the cost to ~``range/window`` windows per cast; callers interpolate onto
393
+ their own grid (e.g. the slow grid for the profile product, depth bins for
394
+ CTD).
395
+
396
+ Parameters
397
+ ----------
398
+ P, T : array_like, shape (n,)
399
+ Pressure [dbar] and in-situ temperature [degC] for one cast.
400
+ S : float, array_like, or None
401
+ Practical salinity (scalar, per-sample, or None → 35 PSU).
402
+ lat, lon : float
403
+ Position for TEOS-10 conversions.
404
+ window : float
405
+ Background vertical window [dbar].
406
+ min_samples, min_dp : int, float
407
+ A window with fewer samples or a smaller pressure span yields NaN.
408
+
409
+ Returns
410
+ -------
411
+ (target_P, N2, dTdz) : tuple of ndarray
412
+ Target pressures [dbar] and the per-target N² [s^-2] and dT/dz
413
+ [K/m vs depth]. ``target_P`` is empty when the cast has too few
414
+ valid samples.
415
+ """
416
+ # Explicit ndarray locals (not reassignments of the ArrayLike params) so
417
+ # mypy keeps the concrete type through .min()/.max(), indexing, and the
418
+ # scalar subtraction below.
419
+ P_arr: np.ndarray = np.asarray(P, dtype=np.float64)
420
+ T_arr: np.ndarray = np.asarray(T, dtype=np.float64)
421
+
422
+ S_arr: np.ndarray | None
423
+ if S is None or np.ndim(S) == 0:
424
+ S_const = 35.0 if S is None else float(S) # type: ignore[arg-type]
425
+ S_arr = None
426
+ else:
427
+ S_arr = np.asarray(S, dtype=np.float64)
428
+
429
+ good = np.isfinite(P_arr) & np.isfinite(T_arr)
430
+ if S_arr is not None:
431
+ good &= np.isfinite(S_arr)
432
+ P_arr, T_arr = P_arr[good], T_arr[good]
433
+ S_arr = S_arr[good] if S_arr is not None else None
434
+ empty = np.empty(0, dtype=np.float64)
435
+ if len(P_arr) < min_samples or np.ptp(P_arr) < min_dp:
436
+ return empty, empty, empty
437
+
438
+ S_vals = S_arr if S_arr is not None else np.full(len(P_arr), S_const)
439
+ SA = gsw.SA_from_SP(S_vals, P_arr, lon, lat)
440
+ CT = gsw.CT_from_t(SA, T_arr, P_arr)
441
+ sigma = gsw.sigma0(SA, CT)
442
+ depth = -gsw.z_from_p(P_arr, lat)
443
+
444
+ half_w = window / 2.0
445
+ step = max(half_w, min_dp)
446
+ p_lo, p_hi = float(P_arr.min()), float(P_arr.max())
447
+ # p_lo < p_hi (ptp >= min_dp guard above) and step > 0, so arange always
448
+ # yields >= 1 target.
449
+ target_P = np.arange(p_lo, p_hi + step, step)
450
+
451
+ N2 = np.full(len(target_P), np.nan)
452
+ dTdz = np.full(len(target_P), np.nan)
453
+ for k, pt in enumerate(target_P):
454
+ sel = np.abs(P_arr - pt) <= half_w
455
+ if np.sum(sel) < min_samples:
456
+ continue
457
+ Pw = P_arr[sel]
458
+ if np.ptp(Pw) < min_dp:
459
+ continue
460
+ N2[k], dTdz[k] = _stable_window(
461
+ Pw, depth[sel], T_arr[sel], SA[sel], CT[sel], sigma[sel], lat, min_dp
462
+ )
463
+
464
+ return target_P, N2, dTdz
465
+
466
+
467
+ def mixing_coefficients(
468
+ epsilon: npt.ArrayLike,
469
+ chi: npt.ArrayLike,
470
+ N2: npt.ArrayLike,
471
+ dTdz: npt.ArrayLike,
472
+ *,
473
+ N2_min: float = DEFAULT_N2_MIN,
474
+ dTdz_min: float = DEFAULT_DTDZ_MIN,
475
+ gamma_osborn: float = GAMMA_OSBORN,
476
+ K_rho_max: float = DEFAULT_K_RHO_MAX,
477
+ K_T_max: float = DEFAULT_K_T_MAX,
478
+ Gamma_max: float = DEFAULT_GAMMA_MAX,
479
+ ) -> MixingResult:
480
+ """Derived mixing quantities from epsilon, chi, and stratification.
481
+
482
+ .. math::
483
+
484
+ K_T = \\frac{\\chi}{2 (\\partial T/\\partial z)^2}, \\qquad
485
+ \\Gamma = \\frac{N^2 \\chi}{2 \\varepsilon
486
+ (\\partial T/\\partial z)^2}, \\qquad
487
+ K_\\rho = \\Gamma_0 \\frac{\\varepsilon}{N^2}
488
+
489
+ Masking (NaN):
490
+
491
+ - ``K_T`` and ``Gamma`` where ``|dT/dz| < dTdz_min`` (well-mixed:
492
+ the temperature-variance budget no longer constrains a
493
+ diffusivity).
494
+ - ``Gamma`` and ``K_rho`` where ``N2 < N2_min`` (unstratified or
495
+ statically unstable at the window scale: the Osborn scaling does
496
+ not apply).
497
+ - Each coefficient where it exceeds its OWN upper sanity bound —
498
+ ``K_rho`` > ``K_rho_max`` (near-floor-``N2`` artifact), ``K_T`` >
499
+ ``K_T_max`` (weak ``dT/dz`` with contaminated ``chi``), ``Gamma`` >
500
+ ``Gamma_max`` (near-zero ``epsilon`` in the denominator). Gating each on
501
+ its own ceiling (rather than masking all three wherever ``K_rho`` is
502
+ masked) keeps a legitimately large ``K_T`` in low-``N2`` water. The masked
503
+ count per variable is reported via :mod:`warnings`.
504
+ - Any output where the corresponding ``chi`` or ``epsilon`` is
505
+ non-finite or non-positive (a positive dissipation rate is required
506
+ for every quantity).
507
+
508
+ Parameters
509
+ ----------
510
+ epsilon : array_like
511
+ TKE dissipation rate [W/kg].
512
+ chi : array_like
513
+ Thermal variance dissipation rate [K^2/s].
514
+ N2 : array_like
515
+ Buoyancy frequency squared [s^-2].
516
+ dTdz : array_like
517
+ Background temperature gradient [K/m] (sign irrelevant; only
518
+ its square is used).
519
+ N2_min, dTdz_min : float
520
+ Validity floors (see module constants).
521
+ gamma_osborn : float
522
+ Constant mixing coefficient for ``K_rho`` (default 0.2).
523
+ K_rho_max : float
524
+ Upper sanity bound on ``K_rho`` [m^2/s]; windows exceeding it are
525
+ masked as physically implausible (see ``DEFAULT_K_RHO_MAX``).
526
+ K_T_max : float
527
+ Upper sanity bound on ``K_T`` [m^2/s] (see ``DEFAULT_K_T_MAX``,
528
+ default 10).
529
+ Gamma_max : float
530
+ Upper sanity bound on the measured ``Gamma`` [1] (see
531
+ ``DEFAULT_GAMMA_MAX``, default 5).
532
+
533
+ Returns
534
+ -------
535
+ MixingResult
536
+ ``K_T`` [m^2/s], ``Gamma`` [1], ``K_rho`` [m^2/s].
537
+ """
538
+ epsilon = np.asarray(epsilon, dtype=np.float64)
539
+ chi = np.asarray(chi, dtype=np.float64)
540
+ N2 = np.asarray(N2, dtype=np.float64)
541
+ dTdz = np.asarray(dTdz, dtype=np.float64)
542
+
543
+ grad_ok = np.isfinite(dTdz) & (np.abs(dTdz) >= dTdz_min)
544
+ strat_ok = np.isfinite(N2) & (N2_min <= N2)
545
+ chi_ok = np.isfinite(chi) & (chi > 0)
546
+ eps_ok = np.isfinite(epsilon) & (epsilon > 0)
547
+
548
+ with np.errstate(divide="ignore", invalid="ignore"):
549
+ K_T = np.where(grad_ok & chi_ok, chi / (2.0 * dTdz**2), np.nan)
550
+ Gamma = np.where(
551
+ grad_ok & strat_ok & chi_ok & eps_ok,
552
+ N2 * chi / (2.0 * epsilon * dTdz**2),
553
+ np.nan,
554
+ )
555
+ K_rho = np.where(strat_ok & eps_ok, gamma_osborn * epsilon / N2, np.nan)
556
+
557
+ # Mask physically implausible magnitudes per variable (audit #30, r-2026-07):
558
+ # each coefficient is gated on its OWN ceiling, not on K_rho's, because they
559
+ # blow up in different regimes — K_rho on near-floor N^2, K_T on a weak dT/dz
560
+ # with contaminated chi, Gamma on a near-zero shear epsilon. A NaN compares
561
+ # False, so only finite over-ceiling windows are masked/counted.
562
+ for name, arr, ceiling, unit in (
563
+ ("K_rho", K_rho, K_rho_max, "m^2/s"),
564
+ ("K_T", K_T, K_T_max, "m^2/s"),
565
+ ("Gamma", Gamma, Gamma_max, ""),
566
+ ):
567
+ over = arr > ceiling
568
+ n_over = int(np.count_nonzero(over))
569
+ if n_over:
570
+ arr[over] = np.nan
571
+ unit_str = f" {unit}" if unit else ""
572
+ warnings.warn(
573
+ f"mixing_coefficients: masked {n_over} {name} value(s) exceeding "
574
+ f"{ceiling}{unit_str} (physically implausible)",
575
+ stacklevel=2,
576
+ )
577
+
578
+ return MixingResult(K_T=K_T, Gamma=Gamma, K_rho=K_rho)
579
+
580
+
581
+ def pair_nearest(
582
+ src_times: npt.ArrayLike,
583
+ src_values: npt.ArrayLike,
584
+ dst_times: npt.ArrayLike,
585
+ max_dt: float | None = None,
586
+ ) -> np.ndarray:
587
+ """Pair each destination time with the nearest source value.
588
+
589
+ Used to align epsilon estimates onto the chi window grid when the
590
+ two were computed with different window lengths. ``max_dt`` (None
591
+ = one median source spacing) rejects pairings with no temporally
592
+ co-located source estimate.
593
+ """
594
+ # Fresh ndarray-typed names (params are npt.ArrayLike; reusing them keeps
595
+ # mypy's declared type as ArrayLike and flags every later index/len).
596
+ src_t = np.asarray(src_times, dtype=np.float64)
597
+ src_v = np.asarray(src_values, dtype=np.float64)
598
+ dst_t = np.asarray(dst_times, dtype=np.float64)
599
+
600
+ out = np.full(len(dst_t), np.nan)
601
+ # Only finite source estimates are candidates: a NaN (QC-rejected) epsilon
602
+ # window must not shadow a valid epsilon at an adjacent window within max_dt
603
+ # (else Gamma/K_rho are silently dropped while K_T survives).
604
+ finite = np.isfinite(src_v)
605
+ if not finite.all():
606
+ src_t = src_t[finite]
607
+ src_v = src_v[finite]
608
+ if len(src_t) == 0:
609
+ return out
610
+ if max_dt is None:
611
+ # Defect (audit): a zero/negative median spacing (duplicate or
612
+ # clamped source times) would collapse the tolerance to <= 0 and
613
+ # silently drop every pairing that is not an exact time match;
614
+ # fall back to a usable positive floor instead.
615
+ med = float(np.median(np.diff(np.sort(src_t)))) if len(src_t) > 1 else 0.0
616
+ max_dt = med if med > 0 else 30.0
617
+ order = np.argsort(src_t)
618
+ st = src_t[order]
619
+ sv = src_v[order]
620
+ if len(st) == 1:
621
+ ok = np.abs(st[0] - dst_t) <= max_dt
622
+ out[ok] = sv[0]
623
+ return out
624
+ idx = np.clip(np.searchsorted(st, dst_t), 1, len(st) - 1)
625
+ left = idx - 1
626
+ pick = np.where(np.abs(st[idx] - dst_t) < np.abs(st[left] - dst_t), idx, left)
627
+ dt = np.abs(st[pick] - dst_t)
628
+ ok = dt <= max_dt
629
+ out[ok] = sv[pick[ok]]
630
+ return out