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,208 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Nasmyth universal shear spectrum.
|
|
3
|
+
|
|
4
|
+
Uses Lueck's improved fit from ODAS v4.5.1, documented in
|
|
5
|
+
McMillan et al. (2016), J. Atmos. Oceanic Techno., 33, 817-837.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import numpy.typing as npt
|
|
12
|
+
|
|
13
|
+
# Lueck (2022), J. Atmos. Oceanic Technol., 39(9), 1259-1271,
|
|
14
|
+
# https://doi.org/10.1175/JTECH-D-21-0051.1
|
|
15
|
+
LUECK_A = 1.0774e9 # e/e_10 model constant
|
|
16
|
+
X_95 = 0.1205 # non-dimensional wavenumber for 95% variance
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def nasmyth(epsilon: float, nu: float, k: npt.ArrayLike) -> np.ndarray:
|
|
20
|
+
"""Nasmyth shear spectrum at given wavenumbers.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
epsilon : float
|
|
25
|
+
Dissipation rate [W/kg].
|
|
26
|
+
nu : float
|
|
27
|
+
Kinematic viscosity [m²/s].
|
|
28
|
+
k : array_like
|
|
29
|
+
Wavenumber(s) [cpm].
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
phi : ndarray
|
|
34
|
+
Shear spectrum [s⁻² cpm⁻¹].
|
|
35
|
+
"""
|
|
36
|
+
if nu <= 0:
|
|
37
|
+
raise ValueError(f"Kinematic viscosity must be positive, got nu={nu}")
|
|
38
|
+
k = np.asarray(k, dtype=np.float64)
|
|
39
|
+
if epsilon <= 0:
|
|
40
|
+
warnings.warn(
|
|
41
|
+
f"epsilon={epsilon:.2e} <= 0; Nasmyth spectrum will contain NaN",
|
|
42
|
+
stacklevel=2,
|
|
43
|
+
)
|
|
44
|
+
return np.full_like(k, np.nan)
|
|
45
|
+
ks = (epsilon / nu**3) ** 0.25 # Kolmogorov wavenumber
|
|
46
|
+
x = k / ks
|
|
47
|
+
G2 = _nasmyth_g2(x)
|
|
48
|
+
return epsilon ** (3 / 4) * nu ** (-1 / 4) * G2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def nasmyth_nondim(x: npt.ArrayLike) -> np.ndarray:
|
|
52
|
+
"""Non-dimensional Nasmyth spectrum G2(x) where x = k/ks.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
x : array_like
|
|
57
|
+
Non-dimensional wavenumber k/ks.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
G2 : ndarray
|
|
62
|
+
Non-dimensional spectrum.
|
|
63
|
+
"""
|
|
64
|
+
return _nasmyth_g2(np.asarray(x, dtype=np.float64))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _nasmyth_g2(x: np.ndarray) -> np.ndarray:
|
|
68
|
+
"""Lueck's improved fit to the Nasmyth spectrum.
|
|
69
|
+
|
|
70
|
+
G2 = 8.05 * x^(1/3) / (1 + (20.6*x)^3.715)
|
|
71
|
+
"""
|
|
72
|
+
return 8.05 * x ** (1 / 3) / (1 + (20.6 * x) ** 3.715)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Grid-based fast lookup for G2(x)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NasmythGrid:
|
|
81
|
+
"""Pre-tabulated Nasmyth G2(x) on a log-spaced grid for fast interpolation.
|
|
82
|
+
|
|
83
|
+
The non-dimensional Nasmyth spectrum G2(x) depends only on x = k/ks.
|
|
84
|
+
By tabulating log10(G2) vs log10(x) once, subsequent evaluations reduce
|
|
85
|
+
to ``np.interp`` in log-space -- avoiding repeated power operations.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
n : int, optional
|
|
90
|
+
Number of grid points (default 2000).
|
|
91
|
+
x_min : float, optional
|
|
92
|
+
Minimum x value (default 1e-6).
|
|
93
|
+
x_max : float, optional
|
|
94
|
+
Maximum x value (default 1.0).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, n: int = 2000, x_min: float = 1e-6, x_max: float = 1.0) -> None:
|
|
98
|
+
self._log10_x = np.linspace(np.log10(x_min), np.log10(x_max), n)
|
|
99
|
+
x_grid = 10.0**self._log10_x
|
|
100
|
+
g2_grid = _nasmyth_g2(x_grid)
|
|
101
|
+
self._log10_g2 = np.log10(g2_grid)
|
|
102
|
+
# Cache boundary values for extrapolation fallback
|
|
103
|
+
self._x_min = x_min
|
|
104
|
+
self._x_max = x_max
|
|
105
|
+
|
|
106
|
+
def interp_g2(self, x: np.ndarray) -> np.ndarray:
|
|
107
|
+
"""Interpolate G2 for arbitrary x values.
|
|
108
|
+
|
|
109
|
+
For x inside [x_min, x_max], log-space interpolation is used.
|
|
110
|
+
For x outside that range, G2 is computed directly via ``_nasmyth_g2``.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
x : ndarray
|
|
115
|
+
Non-dimensional wavenumber(s) k/ks.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
G2 : ndarray
|
|
120
|
+
Non-dimensional spectrum values.
|
|
121
|
+
"""
|
|
122
|
+
# Hot caller pattern (1-D float64 ndarray): skip ``np.asarray`` /
|
|
123
|
+
# ``np.atleast_1d`` / ``.item()``-on-scalar; and use ``np.where``
|
|
124
|
+
# to fold the two-mask split into one allocation. Math identical.
|
|
125
|
+
if isinstance(x, np.ndarray) and x.ndim == 1 and x.dtype == np.float64:
|
|
126
|
+
in_range_mask = (x >= self._x_min) & (x <= self._x_max)
|
|
127
|
+
# Compute the in-range branch on the entire array (interp clips
|
|
128
|
+
# to the lookup table's edges for OOR x; the multiplication by
|
|
129
|
+
# 10**log10_g2 still produces a number). Then overwrite OOR
|
|
130
|
+
# cells with the direct-formula result. This avoids the cost
|
|
131
|
+
# of constructing a boolean-indexed sub-array for the in-range
|
|
132
|
+
# case (which dominates when nearly all values are in range).
|
|
133
|
+
# log10 runs on the whole array (incl. out-of-range x<=0, e.g. a k=0
|
|
134
|
+
# DC bin); those cells are overwritten below, so silence the
|
|
135
|
+
# divide-by-zero / invalid warnings they would otherwise emit.
|
|
136
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
137
|
+
log10_g2 = np.interp(np.log10(x), self._log10_x, self._log10_g2)
|
|
138
|
+
result = 10.0**log10_g2
|
|
139
|
+
if not in_range_mask.all():
|
|
140
|
+
result[~in_range_mask] = _nasmyth_g2(x[~in_range_mask])
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
x = np.asarray(x, dtype=np.float64)
|
|
144
|
+
scalar = x.ndim == 0
|
|
145
|
+
x = np.atleast_1d(x)
|
|
146
|
+
|
|
147
|
+
result = np.empty_like(x)
|
|
148
|
+
|
|
149
|
+
in_range = (x >= self._x_min) & (x <= self._x_max)
|
|
150
|
+
out_range = ~in_range
|
|
151
|
+
|
|
152
|
+
if np.any(in_range):
|
|
153
|
+
log10_x_query = np.log10(x[in_range])
|
|
154
|
+
log10_g2 = np.interp(log10_x_query, self._log10_x, self._log10_g2)
|
|
155
|
+
result[in_range] = 10.0**log10_g2
|
|
156
|
+
|
|
157
|
+
if np.any(out_range):
|
|
158
|
+
result[out_range] = _nasmyth_g2(x[out_range])
|
|
159
|
+
|
|
160
|
+
return result.item() if scalar else result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Module-level singleton, lazily initialized on first call to nasmyth_grid().
|
|
164
|
+
_grid: NasmythGrid | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_grid() -> NasmythGrid:
|
|
168
|
+
"""Return the module-level NasmythGrid, creating it on first access."""
|
|
169
|
+
global _grid
|
|
170
|
+
if _grid is None:
|
|
171
|
+
_grid = NasmythGrid()
|
|
172
|
+
return _grid
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def nasmyth_grid(epsilon: float, nu: float, k: npt.ArrayLike) -> np.ndarray:
|
|
176
|
+
"""Nasmyth shear spectrum using pre-tabulated G2 interpolation.
|
|
177
|
+
|
|
178
|
+
Drop-in replacement for :func:`nasmyth` that avoids repeated power
|
|
179
|
+
operations by interpolating on a pre-computed log-spaced grid of the
|
|
180
|
+
non-dimensional spectrum G2(x).
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
epsilon : float
|
|
185
|
+
Dissipation rate [W/kg].
|
|
186
|
+
nu : float
|
|
187
|
+
Kinematic viscosity [m²/s].
|
|
188
|
+
k : array_like
|
|
189
|
+
Wavenumber(s) [cpm].
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
phi : ndarray
|
|
194
|
+
Shear spectrum [s⁻² cpm⁻¹].
|
|
195
|
+
"""
|
|
196
|
+
if nu <= 0:
|
|
197
|
+
raise ValueError(f"Kinematic viscosity must be positive, got nu={nu}")
|
|
198
|
+
k = np.asarray(k, dtype=np.float64)
|
|
199
|
+
if epsilon <= 0:
|
|
200
|
+
warnings.warn(
|
|
201
|
+
f"epsilon={epsilon:.2e} <= 0; Nasmyth spectrum will contain NaN",
|
|
202
|
+
stacklevel=2,
|
|
203
|
+
)
|
|
204
|
+
return np.full_like(k, np.nan)
|
|
205
|
+
ks = (epsilon / nu**3) ** 0.25 # Kolmogorov wavenumber
|
|
206
|
+
x = k / ks
|
|
207
|
+
G2 = _get_grid().interp_g2(x)
|
|
208
|
+
return epsilon ** (3 / 4) * nu ** (-1 / 4) * G2
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Seawater properties for turbulence calculations."""
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import numpy.typing as npt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def visc35(T: npt.ArrayLike) -> np.ndarray:
|
|
9
|
+
"""Kinematic viscosity of seawater at S=35, P=0 [m²/s].
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
T : float or array_like
|
|
14
|
+
Temperature in °C. Valid for 0-20 °C.
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
nu : float or ndarray
|
|
19
|
+
Kinematic viscosity in m²/s.
|
|
20
|
+
|
|
21
|
+
Reference: ODAS visc35.m — 3rd-order polynomial fit,
|
|
22
|
+
error < 1% for 0 ≤ T ≤ 20 °C, 30 ≤ S ≤ 40 PSU, atmospheric pressure.
|
|
23
|
+
"""
|
|
24
|
+
pol = np.array(
|
|
25
|
+
[
|
|
26
|
+
-1.131311019739306e-011,
|
|
27
|
+
1.199552027472192e-009,
|
|
28
|
+
-5.864346822839289e-008,
|
|
29
|
+
1.828297985908266e-006,
|
|
30
|
+
]
|
|
31
|
+
)
|
|
32
|
+
# The cubic fit (valid 0-20 C) crosses zero near 64.5 C and goes negative
|
|
33
|
+
# beyond it. A single spurious-hot temperature sample would then yield a
|
|
34
|
+
# negative viscosity and make nasmyth raise on sqrt(negative), crashing the
|
|
35
|
+
# whole file. Floor at a small positive value (well below any realistic
|
|
36
|
+
# seawater viscosity, so 0-~60 C is unaffected).
|
|
37
|
+
return np.maximum(np.polyval(pol, T), 1.0e-7)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def visc(
|
|
41
|
+
T: npt.ArrayLike,
|
|
42
|
+
S: npt.ArrayLike = 35,
|
|
43
|
+
P: npt.ArrayLike = 0,
|
|
44
|
+
lon: npt.ArrayLike = 0,
|
|
45
|
+
lat: npt.ArrayLike = 0,
|
|
46
|
+
) -> np.ndarray:
|
|
47
|
+
"""Kinematic viscosity of seawater [m²/s].
|
|
48
|
+
|
|
49
|
+
For S=35, P=0 falls back to the visc35 polynomial (matches ODAS).
|
|
50
|
+
Otherwise uses Sharqawy et al. (2010) for dynamic viscosity
|
|
51
|
+
and gsw (TEOS-10) for in-situ density.
|
|
52
|
+
|
|
53
|
+
Note: the two branches differ by up to ~1.4% at the S=35/P=0 seam (max at
|
|
54
|
+
0 °C; ~0.7% at 5-15 °C), so nu is mildly discontinuous there. This is
|
|
55
|
+
intentional — visc35 is the ODAS reference used by the ATOMIX shear
|
|
56
|
+
benchmark at exactly S=35/P=0, so the seam is not blended. The seam is
|
|
57
|
+
unreachable in normal processing (in-situ P>0 keeps a cast on the Sharqawy
|
|
58
|
+
branch), and a ~1.4% nu change propagates to <0.5% in epsilon (#20).
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
T : float or array_like
|
|
63
|
+
In-situ temperature [°C].
|
|
64
|
+
S : float or array_like
|
|
65
|
+
Practical salinity [PSU]. Default: 35.
|
|
66
|
+
P : float or array_like
|
|
67
|
+
Pressure [dbar]. Default: 0.
|
|
68
|
+
lon, lat : float or array_like
|
|
69
|
+
Longitude [°E] / latitude [°N] for the TEOS-10 Absolute-Salinity
|
|
70
|
+
composition anomaly. Default: 0. The anomaly is tiny at typical
|
|
71
|
+
depths, so this rarely matters; pass the cast position for consistency.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
nu : float or ndarray
|
|
76
|
+
Kinematic viscosity [m²/s].
|
|
77
|
+
"""
|
|
78
|
+
T = np.asarray(T, dtype=float)
|
|
79
|
+
S = np.asarray(S, dtype=float)
|
|
80
|
+
P = np.asarray(P, dtype=float)
|
|
81
|
+
|
|
82
|
+
if np.all(S == 35) and np.all(P == 0):
|
|
83
|
+
return visc35(T)
|
|
84
|
+
|
|
85
|
+
import gsw
|
|
86
|
+
|
|
87
|
+
# Dynamic viscosity of pure water [Pa·s] — Sharqawy et al. (2010) Eq. 22
|
|
88
|
+
mu_w = 4.2844e-5 + (0.157 * (T + 64.993) ** 2 - 91.296) ** (-1)
|
|
89
|
+
|
|
90
|
+
# Salinity correction — Sharqawy et al. (2010) Eq. 23
|
|
91
|
+
A = 1.541 + 1.998e-2 * T - 9.52e-5 * T**2
|
|
92
|
+
B = 7.974 - 7.561e-2 * T + 4.724e-4 * T**2
|
|
93
|
+
S_frac = S * 1e-3 # PSU to mass fraction
|
|
94
|
+
mu = mu_w * (1 + A * S_frac + B * S_frac**2)
|
|
95
|
+
|
|
96
|
+
# In-situ density from gsw (TEOS-10)
|
|
97
|
+
SA = gsw.SA_from_SP(S, P, lon, lat)
|
|
98
|
+
CT = gsw.CT_from_t(SA, T, P)
|
|
99
|
+
rho = gsw.rho(SA, CT, P)
|
|
100
|
+
|
|
101
|
+
# Floor to a small positive value, mirroring visc35: a spurious-cold window
|
|
102
|
+
# mean (T well below 0) drives the Sharqawy polynomial negative, and a
|
|
103
|
+
# negative viscosity crashes the Nasmyth fit downstream.
|
|
104
|
+
return np.maximum(mu / rho, 1.0e-7)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def kappa_T(
|
|
108
|
+
T: npt.ArrayLike,
|
|
109
|
+
S: npt.ArrayLike = 35,
|
|
110
|
+
P: npt.ArrayLike = 0,
|
|
111
|
+
lon: npt.ArrayLike = 0,
|
|
112
|
+
lat: npt.ArrayLike = 0,
|
|
113
|
+
) -> np.ndarray:
|
|
114
|
+
"""Molecular thermal diffusivity of seawater [m²/s].
|
|
115
|
+
|
|
116
|
+
``kappa_T = k / (rho * c_p)`` where the thermal conductivity ``k`` follows
|
|
117
|
+
the Jamieson & Tudhope (1970) correlation as compiled by Sharqawy et al.
|
|
118
|
+
(2010, Eq. 13) and the in-situ density ``rho`` and isobaric specific heat
|
|
119
|
+
``c_p`` come from gsw (TEOS-10, ``gsw.rho`` / ``gsw.cp_t_exact``). This is
|
|
120
|
+
the same style of "published correlation over gsw" path :func:`visc` uses
|
|
121
|
+
off the S=35/P=0 seam; unlike :func:`visc` there is no ODAS-polynomial
|
|
122
|
+
fallback branch, so ``kappa_T`` always uses gsw (there is no ``kappa35``).
|
|
123
|
+
|
|
124
|
+
Over 0-32 °C at S=35, P=0 this rises from ~1.39e-7 to ~1.51e-7 m²/s — the
|
|
125
|
+
~+8% swing the fixed ``chi.batchelor.KAPPA_T = 1.4e-7`` used to miss. Because
|
|
126
|
+
``chi = 6*kappa_T*I`` and ``epsilon_T = (2*pi*kB)^4*nu*kappa_T^2``, using the
|
|
127
|
+
true value removes a chi bias that ran from ~-1% at -1 °C to ~+8% at 32 °C.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
T : float or array_like
|
|
132
|
+
In-situ temperature [°C].
|
|
133
|
+
S : float or array_like
|
|
134
|
+
Practical salinity [PSU]. Default: 35.
|
|
135
|
+
P : float or array_like
|
|
136
|
+
Pressure [dbar]. Default: 0.
|
|
137
|
+
lon, lat : float or array_like
|
|
138
|
+
Longitude [°E] / latitude [°N] for the TEOS-10 Absolute-Salinity
|
|
139
|
+
composition anomaly. Default: 0. Immaterial at typical depths; pass the
|
|
140
|
+
cast position for consistency.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
kappa_T : float or ndarray
|
|
145
|
+
Thermal diffusivity [m²/s].
|
|
146
|
+
|
|
147
|
+
Reference: Sharqawy, Lienhard & Zubair (2010), "Thermophysical properties
|
|
148
|
+
of seawater", Desalination and Water Treatment 16, 354-380 — Eq. 13
|
|
149
|
+
(Jamieson & Tudhope 1970 thermal conductivity, ±3%, 0-180 °C, 0-160 g/kg).
|
|
150
|
+
"""
|
|
151
|
+
import gsw
|
|
152
|
+
|
|
153
|
+
T = np.asarray(T, dtype=float)
|
|
154
|
+
S = np.asarray(S, dtype=float)
|
|
155
|
+
P = np.asarray(P, dtype=float)
|
|
156
|
+
|
|
157
|
+
# Clip a spurious window-mean temperature into the physical seawater range
|
|
158
|
+
# before evaluating ANY term (both the k correlation and the gsw density /
|
|
159
|
+
# specific heat). A wild T from a bad window sends the k polynomial outside
|
|
160
|
+
# its fitted domain AND drives gsw.cp_t_exact / gsw.rho into extrapolation
|
|
161
|
+
# where they can go negative — yielding a negative kappa_T (hence negative
|
|
162
|
+
# chi). -2 °C is below the freezing point at any ocean salinity; 40 °C is
|
|
163
|
+
# above the warmest sea surface. Must clip T for gsw too, not just for k.
|
|
164
|
+
T_c = np.clip(T, -2.0, 40.0)
|
|
165
|
+
|
|
166
|
+
# Thermal conductivity [mW/(m·K)] — Jamieson & Tudhope (1970), Sharqawy Eq. 13
|
|
167
|
+
# (T in °C, S in g/kg ≈ PSU for open-ocean salinities).
|
|
168
|
+
Tk = T_c + 273.15
|
|
169
|
+
log10_k = (
|
|
170
|
+
np.log10(240.0 + 0.0002 * S)
|
|
171
|
+
+ 0.434
|
|
172
|
+
* (2.3 - (343.5 + 0.037 * S) / Tk)
|
|
173
|
+
* (1.0 - Tk / (647.3 + 0.03 * S)) ** (1.0 / 3.0)
|
|
174
|
+
)
|
|
175
|
+
k = 10.0**log10_k * 1.0e-3 # mW/(m·K) → W/(m·K)
|
|
176
|
+
|
|
177
|
+
# Density and isobaric specific heat from gsw (TEOS-10).
|
|
178
|
+
SA = gsw.SA_from_SP(S, P, lon, lat)
|
|
179
|
+
CT = gsw.CT_from_t(SA, T_c, P)
|
|
180
|
+
rho = gsw.rho(SA, CT, P)
|
|
181
|
+
cp = gsw.cp_t_exact(SA, T_c, P)
|
|
182
|
+
|
|
183
|
+
# Floor to a small positive value (mirrors visc/visc35): guards any residual
|
|
184
|
+
# non-physical combination (e.g. a wildly out-of-range salinity) from
|
|
185
|
+
# emitting a zero/negative diffusivity that would corrupt chi downstream.
|
|
186
|
+
return np.maximum(k / (rho * cp), 1.0e-8)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def density(
|
|
190
|
+
T: npt.ArrayLike,
|
|
191
|
+
S: npt.ArrayLike,
|
|
192
|
+
P: npt.ArrayLike,
|
|
193
|
+
lon: npt.ArrayLike = 0,
|
|
194
|
+
lat: npt.ArrayLike = 0,
|
|
195
|
+
) -> np.ndarray:
|
|
196
|
+
"""In-situ density of seawater [kg/m³] via TEOS-10.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
T : float or array_like
|
|
201
|
+
In-situ temperature [°C].
|
|
202
|
+
S : float or array_like
|
|
203
|
+
Practical salinity [PSU].
|
|
204
|
+
P : float or array_like
|
|
205
|
+
Pressure [dbar].
|
|
206
|
+
lon, lat : float or array_like
|
|
207
|
+
Longitude [°E] / latitude [°N] for the TEOS-10 Absolute-Salinity
|
|
208
|
+
composition anomaly. Default: 0. Immaterial at typical depths; pass the
|
|
209
|
+
cast position for consistency.
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
rho : float or ndarray
|
|
214
|
+
In-situ density [kg/m³].
|
|
215
|
+
"""
|
|
216
|
+
import gsw
|
|
217
|
+
|
|
218
|
+
SA = gsw.SA_from_SP(S, P, lon, lat)
|
|
219
|
+
CT = gsw.CT_from_t(SA, T, P)
|
|
220
|
+
return gsw.rho(SA, CT, P)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def buoyancy_freq(
|
|
224
|
+
T: npt.ArrayLike,
|
|
225
|
+
S: npt.ArrayLike,
|
|
226
|
+
P: npt.ArrayLike,
|
|
227
|
+
lat: float = 0,
|
|
228
|
+
min_dp: float = 1e-4,
|
|
229
|
+
lon: npt.ArrayLike = 0,
|
|
230
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
231
|
+
"""Buoyancy frequency squared N² [s⁻²] from profiles.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
T : array_like
|
|
236
|
+
In-situ temperature profile [°C].
|
|
237
|
+
S : array_like
|
|
238
|
+
Practical salinity profile [PSU].
|
|
239
|
+
P : array_like
|
|
240
|
+
Pressure profile [dbar].
|
|
241
|
+
lat : float
|
|
242
|
+
Latitude [°N]. Default: 0. N² is scaled by the latitude-dependent
|
|
243
|
+
gravitational acceleration g(lat) inside ``gsw.Nsquared``; away from
|
|
244
|
+
the equator this matters (≈0.9% bias at 70°N), so supply the cast
|
|
245
|
+
latitude rather than relying on the equatorial default.
|
|
246
|
+
min_dp : float
|
|
247
|
+
Minimum adjacent pressure spacing [dbar]; pairs with smaller spacing
|
|
248
|
+
yield NaN instead of a spurious ``inf`` from the dp=0 division.
|
|
249
|
+
Default: 1e-4.
|
|
250
|
+
lon : float or array_like
|
|
251
|
+
Longitude [°E] for the TEOS-10 Absolute-Salinity composition anomaly
|
|
252
|
+
(threaded into ``SA_from_SP`` alongside ``lat``). Default: 0.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
N2 : ndarray
|
|
257
|
+
Buoyancy frequency squared [s⁻²], length len(P)-1.
|
|
258
|
+
p_mid : ndarray
|
|
259
|
+
Mid-point pressures [dbar], length len(P)-1.
|
|
260
|
+
"""
|
|
261
|
+
import gsw
|
|
262
|
+
|
|
263
|
+
# Defect (audit 103): lat=0 silently uses equatorial gravity, biasing N²
|
|
264
|
+
# for non-equatorial casts. Warn once so the caller knows lat matters.
|
|
265
|
+
if lat == 0:
|
|
266
|
+
import warnings
|
|
267
|
+
|
|
268
|
+
warnings.warn(
|
|
269
|
+
"buoyancy_freq called with lat=0 (equatorial gravity); N² is "
|
|
270
|
+
"gravity-scaled, so supply the cast latitude for non-equatorial "
|
|
271
|
+
"data to avoid a systematic N² bias.",
|
|
272
|
+
stacklevel=2,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Thread the cast lat/lon into Absolute Salinity too (not just the gravity
|
|
276
|
+
# scaling below), so the composition anomaly is consistent. The anomaly is
|
|
277
|
+
# tiny at typical depths (~1e-3 g/kg), so this is correctness hygiene, not a
|
|
278
|
+
# material change; production N2 (processing/mixing.py) already passes lon/lat.
|
|
279
|
+
SA = gsw.SA_from_SP(S, P, lon, lat)
|
|
280
|
+
CT = gsw.CT_from_t(SA, T, P)
|
|
281
|
+
N2, p_mid = gsw.Nsquared(SA, CT, P, lat)
|
|
282
|
+
# Defect (audit 104): gsw.Nsquared divides by adjacent dp; duplicate or
|
|
283
|
+
# non-monotonic pressures (common in binned/turn-around data) give a silent
|
|
284
|
+
# inf. Replace degenerate-spacing entries with NaN, matching mixing.py.
|
|
285
|
+
dp = np.diff(np.asarray(P, dtype=float))
|
|
286
|
+
N2 = np.where(np.abs(dp) < min_dp, np.nan, N2)
|
|
287
|
+
return N2, p_mid
|