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