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,509 @@
1
+ # Jun-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Mixing viewer for Rockland Scientific profiler data.
3
+
4
+ Interactive multi-panel viewer focused on the diapycnal-mixing quantities the
5
+ pipeline derives from epsilon, chi, and the background stratification: the
6
+ buoyancy frequency N², the background temperature gradient dT/dz, the
7
+ Osborn-Cox heat diffusivity K_T, the measured Osborn mixing coefficient
8
+ Γ = N²χ / (2ε(dT/dz)²), and the Osborn diapycnal diffusivity K_rho.
9
+
10
+ Where ``diss_look`` shows only the dimensional χ/ε ratio, this viewer
11
+ computes the *proper* Γ by Thorpe-sorting each window to a stable profile
12
+ (removing overturns) for the background N² and dT/dz, exactly as the
13
+ ``perturb``/``rsi`` pipelines do in their mixing step.
14
+
15
+ Layout (2 rows x 4 columns):
16
+ Row 0 (profile panels, pressure y-axis linked):
17
+ Overview | N² profile | K_T & K_rho profiles | Γ profile
18
+ Row 1:
19
+ dT/dz profile | ε profile | χ profile | K_T vs K_rho scatter
20
+ """
21
+
22
+ import warnings
23
+
24
+ import numpy as np
25
+
26
+ from odas_tpw.processing.mixing import mixing_coefficients, sorted_stratification
27
+ from odas_tpw.rsi.p_file import PFile
28
+ from odas_tpw.rsi.viewer_base import (
29
+ ProfileViewer,
30
+ compute_depth_spectra,
31
+ compute_windowed_diss,
32
+ )
33
+
34
+ # Color cycle: shear-probe (epsilon) and thermistor (chi) families, matching
35
+ # the conventions used by quick_look / diss_look.
36
+ _SHEAR_COLORS = ["C0", "C1", "C4", "C5"]
37
+ _THERM_COLORS = ["C3", "C1", "C4", "C5"]
38
+
39
+
40
+ class MixingLookViewer(ProfileViewer):
41
+ """Interactive mixing viewer with Prev/Next navigation."""
42
+
43
+ _nrows = 3 # extra row for the epsilon/chi spectra and FM/FOM panels
44
+
45
+ def __init__(self, pf, salinity=None, **kwargs):
46
+ super().__init__(pf, **kwargs)
47
+ self._P_win = np.array([])
48
+ self._strat = None
49
+ self._mix = None
50
+ self._eps_mix = np.array([])
51
+ self._chi_mix = np.array([])
52
+
53
+ # Slow-rate practical salinity for the background stratification:
54
+ # an explicit override wins; otherwise the profile's own JAC C/T (when
55
+ # both are present and aligned with the slow time base); otherwise None
56
+ # (-> 35 PSU assumed by sorted_stratification). Mirrors the pipeline's
57
+ # measured > supplied > 35 preference.
58
+ self.salinity: float | np.ndarray | None
59
+ if salinity is not None:
60
+ self.salinity = float(salinity)
61
+ else:
62
+ self.salinity = self._measured_salinity(pf)
63
+
64
+ # Colorbar for the pressure-colored K_T-vs-K_rho scatter; removed and
65
+ # rebuilt each draw so its stolen space never compounds.
66
+ self._scatter_cbar = None
67
+
68
+ def _measured_salinity(self, pf) -> np.ndarray | None:
69
+ """Slow-rate practical salinity from JAC C/T, or None when unavailable."""
70
+ jac_c = pf.channels.get("JAC_C")
71
+ jac_t = pf.channels.get("JAC_T")
72
+ n_slow = len(self.t_slow)
73
+ if (
74
+ jac_c is None
75
+ or jac_t is None
76
+ or len(jac_c) != n_slow
77
+ or len(jac_t) != n_slow
78
+ or len(self.P) != n_slow
79
+ ):
80
+ return None
81
+ import gsw
82
+
83
+ with warnings.catch_warnings():
84
+ warnings.simplefilter("ignore", RuntimeWarning)
85
+ sp = gsw.SP_from_C(np.asarray(jac_c), np.asarray(jac_t), np.asarray(self.P))
86
+ sp = np.asarray(sp, dtype=np.float64)
87
+ return sp if np.isfinite(sp).any() else None
88
+
89
+ # ------------------------------------------------------------------
90
+ # Axis layout
91
+ # ------------------------------------------------------------------
92
+
93
+ def _setup_axes(self):
94
+ p_ref = self.axes[0, 0]
95
+ # Pressure-profile panels share the y-axis. Independent panels: the
96
+ # K_T-vs-K_rho scatter (1, 3) and the two wavenumber spectra (2, 0/1).
97
+ for col in range(1, 4):
98
+ self.axes[0, col].sharey(p_ref)
99
+ for col in range(3): # (1,0) dT/dz, (1,1) eps, (1,2) chi
100
+ self.axes[1, col].sharey(p_ref)
101
+ self.axes[2, 2].sharey(p_ref) # FM profile
102
+ self.axes[2, 3].sharey(p_ref) # chi FOM profile
103
+ p_ref.invert_yaxis()
104
+
105
+ # ------------------------------------------------------------------
106
+ # Per-window combine helpers (match pipeline epsi_final / chi_final)
107
+ # ------------------------------------------------------------------
108
+
109
+ def _window_center_times(self, sel_fast) -> np.ndarray:
110
+ """Window-center times aligned 1:1 with compute_windowed_diss windows.
111
+
112
+ Mirrors the windowing in :func:`compute_windowed_diss` exactly (same
113
+ overlap, step, count, and early-break) so each center time lines up with
114
+ the corresponding ``P_windows`` / epsilon / chi column. The caller
115
+ asserts the lengths match as a drift tripwire.
116
+ """
117
+ diss = self.diss_length
118
+ overlap = diss // 2
119
+ step = diss - overlap
120
+ seg_start, seg_end = sel_fast.start, sel_fast.stop
121
+ n_windows = max(0, 1 + (seg_end - seg_start - diss) // step)
122
+ t_win = np.full(n_windows, np.nan)
123
+ for idx in range(n_windows):
124
+ s = seg_start + idx * step
125
+ e = s + diss
126
+ if e > seg_end:
127
+ break
128
+ t_win[idx] = float(np.mean(self.t_fast[s:e]))
129
+ return t_win
130
+
131
+ def _mixing_epsilon(self, d) -> np.ndarray:
132
+ """Per-window epsilon for mixing: FM<1.15 geometric mean, nanmean fallback.
133
+
134
+ Reproduces the pipeline's QC-then-combine: average the probes that pass
135
+ the Lueck (2022) FM<1.15 quality cut (geometric mean, like
136
+ ``_compute_epsi_final``); if no probe passes a window, fall back to the
137
+ geometric mean of all finite positive probes so the window still yields a
138
+ mixing estimate.
139
+ """
140
+ eps = d["epsilon"] # (n_shear, n_win)
141
+ FM = d["FM"]
142
+ n_shear, n_win = eps.shape
143
+ out = np.full(n_win, np.nan)
144
+ if n_shear == 0 or n_win == 0:
145
+ return out
146
+ for j in range(n_win):
147
+ col = eps[:, j]
148
+ # Keep probes that pass the Lueck (2022) reject criterion FM <= 1.15
149
+ # (reject FM > 1.15), matching the pipeline's fom_limit convention.
150
+ passed = col[np.isfinite(FM[:, j]) & (FM[:, j] <= 1.15) & np.isfinite(col) & (col > 0)]
151
+ pool = passed if passed.size else col[np.isfinite(col) & (col > 0)]
152
+ if pool.size:
153
+ out[j] = float(np.exp(np.mean(np.log(pool))))
154
+ return out
155
+
156
+ def _mixing_chi(self, d) -> np.ndarray:
157
+ """Per-window chi for mixing: mean(Batchelor, Kraichnan) per thermistor,
158
+ then geometric mean across thermistors.
159
+
160
+ Diagnostic approximation only: this pools finite, positive probes but
161
+ does NOT apply the fom / K_max_ratio spectral QC that the published
162
+ ``L4ChiData.chi_final`` now uses (2026-07-03 review, Gemini C), so it can
163
+ differ from the reported chi for windows with a poorly-fit thermistor.
164
+ """
165
+ chi_b = d["chi_batchelor"] # (n_therm, n_win)
166
+ chi_k = d["chi_kraichnan"]
167
+ n_therm, n_win = chi_b.shape
168
+ out = np.full(n_win, np.nan)
169
+ if n_therm == 0 or n_win == 0:
170
+ return out
171
+ per = np.where(
172
+ np.isfinite(chi_b) & np.isfinite(chi_k),
173
+ (chi_b + chi_k) / 2.0,
174
+ np.where(np.isfinite(chi_b), chi_b, chi_k),
175
+ )
176
+ for j in range(n_win):
177
+ col = per[:, j]
178
+ pool = col[np.isfinite(col) & (col > 0)]
179
+ if pool.size:
180
+ out[j] = float(np.exp(np.mean(np.log(pool))))
181
+ return out
182
+
183
+ # ------------------------------------------------------------------
184
+ # Draw
185
+ # ------------------------------------------------------------------
186
+
187
+ def _draw(self):
188
+ s_slow, e_slow = self.profiles[self.profile_idx]
189
+ sel_fast = self._slow_to_fast_slice(s_slow, e_slow)
190
+ self._spec_bin_width = self._spec_bin_dbar(s_slow, e_slow)
191
+ sel_spec = self._spec_fast_slice(sel_fast)
192
+
193
+ if self._scatter_cbar is not None:
194
+ self._scatter_cbar.remove()
195
+ self._scatter_cbar = None
196
+
197
+ with warnings.catch_warnings():
198
+ warnings.simplefilter("ignore", UserWarning)
199
+ for ax in self.axes.flat:
200
+ ax.clear()
201
+ self.axes[0, 0].invert_yaxis()
202
+
203
+ d = compute_windowed_diss(
204
+ self.shear,
205
+ self.accel,
206
+ self.therm_fast,
207
+ self.diff_gains,
208
+ self.P_fast,
209
+ self.T,
210
+ self.speed_fast,
211
+ self.fs_fast,
212
+ sel_fast,
213
+ self.fft_length,
214
+ self.f_AA,
215
+ self.goodman,
216
+ diss_length=self.diss_length,
217
+ )
218
+ self._cached_diss = d
219
+ P = d["P_windows"]
220
+ self._P_win = P
221
+
222
+ # Spectra at the selected depth for the row-2 spectral panels.
223
+ self._cached_spec = compute_depth_spectra(
224
+ self.shear,
225
+ self.accel,
226
+ self.therm_fast,
227
+ self.diff_gains,
228
+ self.P_fast,
229
+ self.T,
230
+ self.speed_fast,
231
+ self.fs_fast,
232
+ sel_spec,
233
+ self.fft_length,
234
+ self.f_AA,
235
+ self.goodman,
236
+ diss_length=self.diss_length,
237
+ )
238
+
239
+ # Background stratification on the Thorpe-sorted slow profile, then the
240
+ # mixing coefficients on the same window grid.
241
+ t_win = self._window_center_times(sel_fast)
242
+ assert len(t_win) == len(P), "window-time grid drifted from compute_windowed_diss"
243
+ half_w = 0.5 * self.diss_length / self.fs_fast
244
+ with warnings.catch_warnings():
245
+ warnings.simplefilter("ignore", RuntimeWarning)
246
+ self._strat = sorted_stratification(
247
+ t_win, half_w, self.t_slow, self.P, self.T, S=self.salinity
248
+ )
249
+ self._eps_mix = self._mixing_epsilon(d)
250
+ self._chi_mix = self._mixing_chi(d)
251
+ self._mix = mixing_coefficients(
252
+ self._eps_mix, self._chi_mix, self._strat.N2, self._strat.dTdz
253
+ )
254
+
255
+ # Row 0
256
+ self._draw_overview(self.axes[0, 0], s_slow, e_slow)
257
+ self._draw_n2(self.axes[0, 1])
258
+ self._draw_diffusivities(self.axes[0, 2])
259
+ self._draw_gamma(self.axes[0, 3])
260
+
261
+ # Row 1
262
+ self._draw_dtdz(self.axes[1, 0])
263
+ self._draw_eps_profile(self.axes[1, 1], P, d["epsilon"])
264
+ self._draw_chi_profile(self.axes[1, 2])
265
+ self._draw_kt_krho_scatter(self.axes[1, 3])
266
+
267
+ # Row 2: spectra at depth + Lueck FM / chi FOM (shared base panels)
268
+ self._draw_eps_spectra(self.axes[2, 0], sel_spec, self._cached_spec)
269
+ self._draw_chi_m1m2_spectra(self.axes[2, 1], sel_spec)
270
+ self._draw_fm_profile(self.axes[2, 2])
271
+ self._draw_chi_fom_profile(self.axes[2, 3])
272
+
273
+ pressure_axes = (
274
+ [self.axes[0, c] for c in range(4)]
275
+ + [self.axes[1, c] for c in range(3)]
276
+ + [self.axes[2, 2], self.axes[2, 3]]
277
+ )
278
+ self._finish_draw(s_slow, e_slow, pressure_axes)
279
+
280
+ # ------------------------------------------------------------------
281
+ # Panels
282
+ # ------------------------------------------------------------------
283
+
284
+ @staticmethod
285
+ def _no_data(ax, msg):
286
+ ax.text(0.5, 0.5, msg, transform=ax.transAxes, ha="center", va="center")
287
+
288
+ def _draw_n2(self, ax):
289
+ """Panel (0,1): background N² vs pressure (log; only N² > 0)."""
290
+ P = self._P_win
291
+ N2 = self._strat.N2 if self._strat is not None else np.array([])
292
+ valid = np.isfinite(N2) & (N2 > 0) & np.isfinite(P)
293
+ if not np.any(valid):
294
+ self._no_data(ax, "No stratified N²")
295
+ return
296
+ ax.plot(N2[valid], P[valid], "C2o-", markersize=3, linewidth=0.8)
297
+ ax.set_xscale("log")
298
+ ax.set_xlabel("N² [s⁻²]")
299
+ ax.set_ylabel("Pressure [dbar]")
300
+ ax.set_title("N² (Thorpe-sorted)", fontsize=9)
301
+ ax.grid(True, alpha=0.3, which="both")
302
+
303
+ def _draw_dtdz(self, ax):
304
+ """Panel (1,0): background dT/dz vs pressure (linear; positive down)."""
305
+ P = self._P_win
306
+ dTdz = self._strat.dTdz if self._strat is not None else np.array([])
307
+ valid = np.isfinite(dTdz) & np.isfinite(P)
308
+ if not np.any(valid):
309
+ self._no_data(ax, "No dT/dz")
310
+ return
311
+ ax.plot(dTdz[valid], P[valid], "C5o-", markersize=3, linewidth=0.8)
312
+ ax.axvline(0.0, color="k", linewidth=0.7, alpha=0.5)
313
+ ax.set_xlabel("dT/dz [K m⁻¹]")
314
+ ax.set_ylabel("Pressure [dbar]")
315
+ ax.set_title("Background dT/dz", fontsize=9)
316
+ ax.grid(True, alpha=0.3)
317
+
318
+ def _draw_diffusivities(self, ax):
319
+ """Panel (0,2): K_T (Osborn-Cox) and K_rho (Osborn) vs pressure (log)."""
320
+ P = self._P_win
321
+ if self._mix is None:
322
+ self._no_data(ax, "No data")
323
+ return
324
+ has = False
325
+ for vals, color, label in (
326
+ (self._mix.K_T, "C0", r"$K_T$ (Osborn-Cox)"),
327
+ (self._mix.K_rho, "C3", r"$K_\rho$ (Osborn)"),
328
+ ):
329
+ valid = np.isfinite(vals) & (vals > 0) & np.isfinite(P)
330
+ if np.any(valid):
331
+ ax.plot(vals[valid], P[valid], "o-", color=color, markersize=3,
332
+ linewidth=0.8, label=label)
333
+ has = True
334
+ if not has:
335
+ self._no_data(ax, "No valid K")
336
+ return
337
+ ax.set_xscale("log")
338
+ ax.set_xlabel("K [m² s⁻¹]")
339
+ ax.set_ylabel("Pressure [dbar]")
340
+ ax.legend(fontsize=6, loc="lower left")
341
+ ax.set_title(r"Diffusivity: $K_T$ vs $K_\rho$", fontsize=9)
342
+ ax.grid(True, alpha=0.3, which="both")
343
+
344
+ def _draw_gamma(self, ax):
345
+ """Panel (0,3): measured mixing coefficient Γ vs pressure (log; ref 0.2)."""
346
+ P = self._P_win
347
+ Gamma = self._mix.Gamma if self._mix is not None else np.array([])
348
+ valid = np.isfinite(Gamma) & (Gamma > 0) & np.isfinite(P)
349
+ if not np.any(valid):
350
+ self._no_data(ax, "No valid Γ")
351
+ return
352
+ ax.plot(Gamma[valid], P[valid], "C4o-", markersize=3, linewidth=0.8)
353
+ ax.axvline(0.2, color="k", linestyle="--", linewidth=0.9, alpha=0.7, label="Γ = 0.2")
354
+ ax.set_xscale("log")
355
+ ax.set_xlabel("Γ = N²χ / (2ε(dT/dz)²)")
356
+ ax.set_ylabel("Pressure [dbar]")
357
+ ax.legend(fontsize=6, loc="lower left")
358
+ ax.set_title("Mixing coefficient Γ", fontsize=9)
359
+ ax.grid(True, alpha=0.3, which="both")
360
+
361
+ def _draw_chi_profile(self, ax):
362
+ """Panel (1,2): chi per thermistor vs pressure (mean of Batchelor & Kraichnan)."""
363
+ d = self._cached_diss
364
+ P = self._P_win
365
+ if d is None or len(P) == 0:
366
+ self._no_data(ax, "No data")
367
+ return
368
+ has = False
369
+ for i, (name, _) in enumerate(self.therm_fast):
370
+ c = _THERM_COLORS[i % len(_THERM_COLORS)]
371
+ chi_b = d["chi_batchelor"][i]
372
+ chi_k = d["chi_kraichnan"][i]
373
+ chi_mean = np.where(
374
+ np.isfinite(chi_b) & np.isfinite(chi_k),
375
+ (chi_b + chi_k) / 2.0,
376
+ np.where(np.isfinite(chi_b), chi_b, chi_k),
377
+ )
378
+ valid = np.isfinite(chi_mean) & (chi_mean > 0)
379
+ if np.any(valid):
380
+ ax.plot(chi_mean[valid], P[valid], f"{c}o-", markersize=3,
381
+ linewidth=0.8, label=name)
382
+ has = True
383
+ if not has:
384
+ self._no_data(ax, "No valid χ")
385
+ return
386
+ ax.set_xscale("log")
387
+ ax.set_xlabel("χ [K²/s]")
388
+ ax.set_ylabel("Pressure [dbar]")
389
+ ax.legend(fontsize=6, loc="lower left")
390
+ ax.set_title("χ profile (Batch. + Kraich.)", fontsize=9)
391
+ ax.grid(True, alpha=0.3, which="both")
392
+
393
+ def _draw_kt_krho_scatter(self, ax):
394
+ """Panel (1,3): K_T vs K_rho (log-log, 1:1 line), colored by pressure.
395
+
396
+ Points on the 1:1 line have measured Γ ≈ 0.2 (K_T ≈ K_rho); the pressure
397
+ color shows how the agreement varies with depth.
398
+ """
399
+ if self._mix is None:
400
+ self._no_data(ax, "No data")
401
+ return
402
+ K_T = self._mix.K_T
403
+ K_rho = self._mix.K_rho
404
+ valid = (
405
+ np.isfinite(K_T) & (K_T > 0) & np.isfinite(K_rho) & (K_rho > 0)
406
+ & np.isfinite(self._P_win)
407
+ )
408
+ if not np.any(valid):
409
+ self._no_data(ax, "No valid K_T/K_rho")
410
+ return
411
+ x = K_rho[valid]
412
+ y = K_T[valid]
413
+ pcol = self._P_win[valid]
414
+ ax.set_xscale("log")
415
+ ax.set_yscale("log")
416
+ sc = ax.scatter(x, y, c=pcol, cmap="viridis", s=26, alpha=0.85, edgecolors="none")
417
+ assert self.fig is not None
418
+ cbar = self.fig.colorbar(sc, ax=ax, pad=0.02)
419
+ cbar.set_label("Pressure [dbar]", fontsize=7)
420
+ cbar.ax.tick_params(labelsize=6)
421
+ cbar.ax.invert_yaxis() # shallow at top, deep at bottom (matches profiles)
422
+ self._scatter_cbar = cbar
423
+ lo = float(min(x.min(), y.min()))
424
+ hi = float(max(x.max(), y.max()))
425
+ ax.plot([lo, hi], [lo, hi], "k--", linewidth=0.9, alpha=0.7, label="1:1 (Γ = 0.2)")
426
+ ax.set_xlabel(r"$K_\rho$ (Osborn) [m² s⁻¹]")
427
+ ax.set_ylabel(r"$K_T$ (Osborn-Cox) [m² s⁻¹]")
428
+ ax.legend(fontsize=6, loc="upper left")
429
+ ax.set_title(r"$K_T$ vs $K_\rho$ (color = P)", fontsize=9)
430
+ ax.grid(True, alpha=0.3, which="both")
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Entry point
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ def mixing_look(
439
+ source,
440
+ fft_length=1024,
441
+ f_AA=98.0,
442
+ goodman=True,
443
+ direction="auto",
444
+ P_min=0.5,
445
+ W_min=0.3,
446
+ min_duration=7.0,
447
+ spec_P_range=None,
448
+ diss_length=None,
449
+ vehicle=None,
450
+ salinity=None,
451
+ ) -> MixingLookViewer:
452
+ """Open an interactive mixing viewer for a .p file.
453
+
454
+ Displays the background stratification (N², dT/dz) and the derived
455
+ diapycnal-mixing quantities (K_T, Γ, K_rho), computed exactly as the
456
+ pipeline does: per-window epsilon and chi with the background N²/dT/dz
457
+ from a Thorpe-sorted (overturn-removed) profile.
458
+
459
+ Parameters
460
+ ----------
461
+ source : str or Path
462
+ Path to a Rockland .p file.
463
+ fft_length : int
464
+ FFT segment length for spectral estimation (default: 1024).
465
+ f_AA : float
466
+ Anti-aliasing filter cutoff [Hz].
467
+ goodman : bool
468
+ Apply Goodman coherent noise removal to shear spectra.
469
+ direction : str
470
+ Profile direction: 'auto', 'up', 'down', 'glide', or 'horizontal'.
471
+ P_min : float
472
+ Minimum pressure for profile detection [dbar].
473
+ W_min : float
474
+ Minimum fall rate for profile detection [dbar/s].
475
+ min_duration : float
476
+ Minimum profile duration [s].
477
+ spec_P_range : tuple of (float, float) or None
478
+ Pressure range (P_min, P_max) in dbar to highlight on the profiles.
479
+ diss_length : int or None
480
+ Dissipation window length [samples]. Default: 4 * fft_length.
481
+ vehicle : str or None
482
+ Vehicle type override (e.g. 'slocum_glider'). If None, read from
483
+ instrument config.
484
+ salinity : float or None
485
+ Fixed practical salinity [PSU] for the stratification. If None, the
486
+ profile's own JAC C/T conductivity is used when available, else 35.
487
+
488
+ Returns
489
+ -------
490
+ MixingLookViewer
491
+ The viewer instance.
492
+ """
493
+ pf = PFile(source)
494
+ viewer = MixingLookViewer(
495
+ pf,
496
+ fft_length=fft_length,
497
+ f_AA=f_AA,
498
+ goodman=goodman,
499
+ direction=direction,
500
+ P_min=P_min,
501
+ W_min=W_min,
502
+ min_duration=min_duration,
503
+ spec_P_range=spec_P_range,
504
+ diss_length=diss_length,
505
+ vehicle=vehicle,
506
+ salinity=salinity,
507
+ )
508
+ viewer.show()
509
+ return viewer