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