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,758 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Interactive quick-look viewer for Rockland Scientific profiler data.
3
+
4
+ Port of quick_look.m from the ODAS MATLAB library.
5
+ Provides a multi-panel figure with Prev/Next buttons to step through profiles.
6
+
7
+ Layout (2 rows x 4 columns):
8
+ Row 0 (profile panels, pressure y-axis linked):
9
+ Overview | epsilon profile | chi profile | Shear probes
10
+ Row 1 (spectra + detail):
11
+ T1, T2, JAC_T | epsilon spectra | chi spectra | Fall rate
12
+ """
13
+
14
+ import warnings
15
+
16
+ import numpy as np
17
+
18
+ from odas_tpw.rsi.p_file import PFile
19
+ from odas_tpw.rsi.viewer_base import (
20
+ ProfileViewer,
21
+ _interp_slow_to_fast,
22
+ compute_depth_spectra,
23
+ select_mid_window,
24
+ )
25
+ from odas_tpw.rsi.window import compute_chi_window, compute_eps_window
26
+ from odas_tpw.scor160.nasmyth import nasmyth
27
+ from odas_tpw.scor160.ocean import visc35
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # ql-specific free functions
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _compute_chi_spectra(
35
+ therm_data,
36
+ diff_gains,
37
+ P_fast,
38
+ T_slow,
39
+ speed_fast,
40
+ fs_fast,
41
+ sel,
42
+ fft_length,
43
+ f_AA,
44
+ epsilons,
45
+ foms=None,
46
+ spectrum_model="kraichnan",
47
+ ):
48
+ """Compute temperature gradient spectra and all three chi methods for one profile segment.
49
+
50
+ Uses shared ``compute_chi_window`` from ``window.py`` for Method 1 (chi
51
+ from epsilon) and Method 2 Iterative (Peterson & Fer 2014). Method 2 MLE
52
+ uses ``_mle_fit_kB`` directly with the correct gradient spectrum from the
53
+ shared function.
54
+
55
+ Selects a single diss_length window closest to the midpoint pressure,
56
+ matching diss_look and MATLAB tpw_plot_spectra.
57
+
58
+ Returns (K, F, obs_spectra, methods_results, noise_K, mean_speed, nu).
59
+
60
+ methods_results is a dict keyed by method name ("M1", "M2-MLE", "M2-Iter"),
61
+ each mapping to a list of per-probe (batch_spec, chi_val, kB_val) tuples.
62
+ """
63
+ from odas_tpw.chi.chi import _mle_fit_kB
64
+ from odas_tpw.chi.fp07 import fp07_tau, fp07_transfer
65
+ from odas_tpw.chi.fp07 import gradT_noise as _gradT_noise
66
+ from odas_tpw.scor160.ocean import kappa_T as _kappa_T_TSP
67
+
68
+ w_sel = select_mid_window(P_fast, sel, fft_length, diss_length=None)
69
+
70
+ # Per-window mean temperature (was the whole-cast mean -> ~14% visc bias).
71
+ mean_T = float(np.mean(_interp_slow_to_fast(T_slow, len(P_fast))[w_sel]))
72
+ # T-dependent thermal diffusivity (S=35/P=0 reference for this diagnostic).
73
+ kappa_T_ql = float(_kappa_T_TSP(mean_T))
74
+ mean_speed = float(np.mean(np.abs(speed_fast[w_sel])))
75
+ if mean_speed < 0.01:
76
+ mean_speed = 0.01
77
+ nu = float(visc35(mean_T))
78
+
79
+ n_therm = len(therm_data)
80
+ n_freq = fft_length // 2 + 1
81
+ f_AA_chi = 0.9 * f_AA # 10% margin, matching MATLAB get_chi
82
+
83
+ therm_segs = [therm_data[ci][1][w_sel] for ci in range(n_therm)]
84
+ eps_arr = np.array(epsilons) if epsilons else None
85
+ fom_arr = np.array(foms) if foms else None
86
+
87
+ # Method 1: chi from epsilon (FOM-filtered mean of shear probes)
88
+ cr_m1 = compute_chi_window(
89
+ therm_segs,
90
+ diff_gains,
91
+ mean_speed,
92
+ mean_T,
93
+ nu,
94
+ fs_fast,
95
+ fft_length,
96
+ f_AA_chi,
97
+ spectrum_model=spectrum_model,
98
+ epsilon=eps_arr,
99
+ fom=fom_arr,
100
+ method=1,
101
+ )
102
+
103
+ # Method 2 Iterative
104
+ cr_m2_iter = compute_chi_window(
105
+ therm_segs,
106
+ diff_gains,
107
+ mean_speed,
108
+ mean_T,
109
+ nu,
110
+ fs_fast,
111
+ fft_length,
112
+ f_AA_chi,
113
+ spectrum_model=spectrum_model,
114
+ method=2,
115
+ )
116
+
117
+ K = cr_m1.K
118
+ F = cr_m1.F
119
+ H2 = cr_m1.H2
120
+ obs_spectra = cr_m1.grad_specs
121
+ noise_K = cr_m1.noise_K
122
+ K_AA_chi = f_AA_chi / mean_speed
123
+
124
+ nan_result = (np.full(n_freq, np.nan), np.nan, np.nan)
125
+ methods_results: dict[str, list] = {"M1": [], "M2-MLE": [], "M2-Iter": []}
126
+
127
+ for ci in range(n_therm):
128
+ # M1
129
+ if np.isfinite(cr_m1.chi[ci]) and cr_m1.chi[ci] > 0:
130
+ methods_results["M1"].append(
131
+ (cr_m1.model_specs[ci], float(cr_m1.chi[ci]), float(cr_m1.kB[ci]))
132
+ )
133
+ else:
134
+ methods_results["M1"].append(nan_result)
135
+
136
+ # M2-Iter
137
+ if np.isfinite(cr_m2_iter.chi[ci]) and cr_m2_iter.chi[ci] > 0:
138
+ methods_results["M2-Iter"].append(
139
+ (cr_m2_iter.model_specs[ci], float(cr_m2_iter.chi[ci]), float(cr_m2_iter.kB[ci]))
140
+ )
141
+ else:
142
+ methods_results["M2-Iter"].append(nan_result)
143
+
144
+ # M2-MLE: call _mle_fit_kB directly with gradient spectrum from shared code
145
+ grad = obs_spectra[ci] if ci < len(obs_spectra) else np.full(n_freq, np.nan)
146
+ dg_i = diff_gains[ci] if ci < len(diff_gains) else 0.94
147
+ valid = np.isfinite(grad) & (grad > 0) & (K > 0) & (K_AA_chi >= K)
148
+ if np.sum(valid) >= 3:
149
+ chi_obs = max(6 * kappa_T_ql * np.trapezoid(grad[valid], K[valid]), 1e-10)
150
+ # Pre-compute noise/H2 for _mle_fit_kB
151
+ _tau0 = float(fp07_tau(mean_speed))
152
+ _noise_K_i, _ = _gradT_noise(F, mean_T, mean_speed, fs=fs_fast, diff_gain=dg_i)
153
+ kB_best, chi_val, _, _, spec_raw, _, _, _ = _mle_fit_kB(
154
+ grad,
155
+ K,
156
+ chi_obs,
157
+ nu,
158
+ _noise_K_i,
159
+ H2,
160
+ _tau0,
161
+ fp07_transfer,
162
+ f_AA_chi,
163
+ mean_speed,
164
+ spectrum_model,
165
+ kappa_T_ql,
166
+ )
167
+ spec_mle = spec_raw * H2 if np.isfinite(chi_val) else np.full(n_freq, np.nan)
168
+ methods_results["M2-MLE"].append((spec_mle, chi_val, kB_best))
169
+ else:
170
+ methods_results["M2-MLE"].append(nan_result)
171
+
172
+ return K, F, obs_spectra, methods_results, noise_K, mean_speed, nu
173
+
174
+
175
+ def _compute_windowed_eps_chi(
176
+ shear_data,
177
+ accel_data,
178
+ therm_data,
179
+ diff_gains,
180
+ P_fast,
181
+ T_slow,
182
+ speed_fast,
183
+ fs_fast,
184
+ sel,
185
+ fft_length,
186
+ f_AA,
187
+ do_goodman,
188
+ chi_method=1,
189
+ spectrum_model="kraichnan",
190
+ diss_length=None,
191
+ ):
192
+ """Compute windowed epsilon and chi estimates for a profile segment.
193
+
194
+ Returns (P_windows, eps_array, chi_array).
195
+ eps_array shape: (n_shear, n_windows)
196
+ chi_array shape: (n_therm, n_windows)
197
+
198
+ Parameters
199
+ ----------
200
+ chi_method : int
201
+ 1 = chi from known epsilon (Method 1), 2 = iterative fit (Method 2).
202
+ spectrum_model : str
203
+ Theoretical spectrum: 'batchelor' or 'kraichnan'.
204
+ diss_length : int or None
205
+ Dissipation window length. Default: 4 * fft_length.
206
+ """
207
+ if diss_length is None:
208
+ diss_length = 4 * fft_length
209
+ overlap = diss_length // 2
210
+ step = diss_length - overlap
211
+
212
+ seg_start = sel.start
213
+ seg_end = sel.stop
214
+ N = seg_end - seg_start
215
+
216
+ n_shear = len(shear_data)
217
+ n_therm = len(therm_data)
218
+ n_windows = max(0, 1 + (N - diss_length) // step)
219
+ if n_windows == 0:
220
+ return np.array([]), np.empty((n_shear, 0)), np.empty((n_therm, 0))
221
+
222
+ P_windows = np.full(n_windows, np.nan)
223
+ eps_arr = np.full((n_shear, n_windows), np.nan)
224
+ chi_arr = np.full((n_therm, n_windows), np.nan)
225
+
226
+ # Full-length fast temperature: the window loop slices T_fast with ABSOLUTE
227
+ # fast indices (s = seg_start + idx*step), exactly as it slices the
228
+ # full-length P_fast/speed_fast/shear arrays. Sizing T_fast to the segment
229
+ # length N instead made every window past index N empty -> mean_T NaN ->
230
+ # all-NaN eps/chi for every profile after the first, and compressed the
231
+ # whole-record temperature onto profile 0's segment (biased viscosity).
232
+ # Regression from PR #75 M-7; matches viewer_base._interp_slow_to_fast use
233
+ # (audit r1-13).
234
+ T_fast = _interp_slow_to_fast(T_slow, len(P_fast))
235
+ f_AA_chi = 0.9 * f_AA # 10% margin, matching MATLAB get_chi
236
+
237
+ for idx in range(n_windows):
238
+ s = seg_start + idx * step
239
+ e = s + diss_length
240
+ if e > seg_end:
241
+ break
242
+ w_sel = slice(s, e)
243
+ # Per-window mean temperature (was the whole-cast mean -> ~14% bias).
244
+ mean_T = float(np.mean(T_fast[w_sel]))
245
+ P_windows[idx] = float(np.mean(P_fast[w_sel]))
246
+
247
+ # --- Epsilon ---
248
+ er = None
249
+ if n_shear > 0:
250
+ sh_seg = np.column_stack([d[w_sel] for _, d in shear_data])
251
+ ac_seg = np.column_stack([d[w_sel] for _, d in accel_data]) if accel_data else None
252
+ er = compute_eps_window(
253
+ sh_seg,
254
+ ac_seg,
255
+ speed_fast[w_sel],
256
+ P_fast[w_sel],
257
+ mean_T,
258
+ fs_fast,
259
+ fft_length,
260
+ f_AA,
261
+ do_goodman,
262
+ )
263
+ eps_arr[:, idx] = er.epsilon
264
+
265
+ # --- Chi ---
266
+ if n_therm > 0 and er is not None:
267
+ therm_segs = [therm_data[ci][1][w_sel] for ci in range(n_therm)]
268
+ method = 1 if chi_method == 1 else 2
269
+ eps_for_chi = er.epsilon if chi_method == 1 else None
270
+ cr = compute_chi_window(
271
+ therm_segs,
272
+ diff_gains,
273
+ er.W,
274
+ mean_T,
275
+ er.nu,
276
+ fs_fast,
277
+ fft_length,
278
+ f_AA_chi,
279
+ spectrum_model=spectrum_model,
280
+ epsilon=eps_for_chi,
281
+ method=method,
282
+ )
283
+ chi_arr[:, idx] = cr.chi
284
+
285
+ return P_windows, eps_arr, chi_arr
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # QuickLookViewer
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ class QuickLookViewer(ProfileViewer):
294
+ """Interactive multi-panel profile viewer with Prev/Next navigation."""
295
+
296
+ def __init__(self, pf, chi_method=1, spectrum_model="kraichnan", **kwargs):
297
+ super().__init__(pf, **kwargs)
298
+ self.chi_method = chi_method
299
+ self.spectrum_model = spectrum_model
300
+
301
+ def _setup_axes(self):
302
+ # Link pressure y-axes: row 0 all cols + row 1 cols 0, 3
303
+ p_ref = self.axes[0, 0]
304
+ for col in range(1, 4):
305
+ self.axes[0, col].sharey(p_ref)
306
+ self.axes[1, 0].sharey(p_ref)
307
+ self.axes[1, 3].sharey(p_ref)
308
+ p_ref.invert_yaxis()
309
+
310
+ # Link wavenumber x-axes: epsilon spectra (1,1) and chi spectra (1,2)
311
+ self.axes[1, 2].sharex(self.axes[1, 1])
312
+
313
+ def _draw(self):
314
+ """Draw all panels for the current profile."""
315
+ s_slow, e_slow = self.profiles[self.profile_idx]
316
+ sel_fast = self._slow_to_fast_slice(s_slow, e_slow)
317
+ self._spec_bin_width = self._spec_bin_dbar(s_slow, e_slow)
318
+ sel_spec = self._spec_fast_slice(sel_fast)
319
+
320
+ with warnings.catch_warnings():
321
+ warnings.simplefilter("ignore", UserWarning)
322
+ for ax in self.axes.flat:
323
+ ax.clear()
324
+ # Re-invert pressure y-axes (clear resets inversion)
325
+ self.axes[0, 0].invert_yaxis()
326
+
327
+ # Pre-compute depth spectra (dict format from viewer_base)
328
+ n_spec = sel_spec.stop - sel_spec.start
329
+ self._cached_spec = None
330
+ if self.shear and n_spec >= self.diss_length:
331
+ self._cached_spec = compute_depth_spectra(
332
+ self.shear,
333
+ self.accel,
334
+ self.therm_fast,
335
+ self.diff_gains,
336
+ self.P_fast,
337
+ self.T,
338
+ self.speed_fast,
339
+ self.fs_fast,
340
+ sel_spec,
341
+ self.fft_length,
342
+ self.f_AA,
343
+ self.goodman,
344
+ diss_length=self.diss_length,
345
+ )
346
+
347
+ # Pre-compute windowed eps/chi for profile panels
348
+ n_fast = sel_fast.stop - sel_fast.start
349
+ self._cached_windowed = None
350
+ if n_fast >= self.diss_length:
351
+ P_win, eps_arr, chi_arr = _compute_windowed_eps_chi(
352
+ self.shear,
353
+ self.accel,
354
+ self.therm_fast,
355
+ self.diff_gains,
356
+ self.P_fast,
357
+ self.T,
358
+ self.speed_fast,
359
+ self.fs_fast,
360
+ sel_fast,
361
+ self.fft_length,
362
+ self.f_AA,
363
+ self.goodman,
364
+ chi_method=self.chi_method,
365
+ spectrum_model=self.spectrum_model,
366
+ diss_length=self.diss_length,
367
+ )
368
+ if len(P_win) > 0:
369
+ self._cached_windowed = (P_win, eps_arr, chi_arr)
370
+
371
+ # Row 0: profile panels
372
+ self._draw_overview(self.axes[0, 0], s_slow, e_slow)
373
+ P_win = self._cached_windowed[0] if self._cached_windowed else np.array([])
374
+ eps_arr = (
375
+ self._cached_windowed[1] if self._cached_windowed else np.empty((len(self.shear), 0))
376
+ )
377
+ self._draw_eps_profile(self.axes[0, 1], P_win, eps_arr)
378
+ self._draw_chi_profile()
379
+ self._draw_shear(self.axes[0, 3], sel_fast)
380
+
381
+ # Row 1: spectra and detail panels
382
+ self._draw_temps(self.axes[1, 0], s_slow, e_slow)
383
+ self._draw_spectra(sel_spec)
384
+ self._draw_chi_spectra(sel_spec)
385
+ self._draw_fall_rate(self.axes[1, 3], s_slow, e_slow)
386
+
387
+ # Finish (green band + title)
388
+ pressure_axes = [self.axes[0, c] for c in range(4)] + [self.axes[1, 0], self.axes[1, 3]]
389
+ self._finish_draw(s_slow, e_slow, pressure_axes)
390
+
391
+ # ------------------------------------------------------------------
392
+ # ql-specific panels
393
+ # ------------------------------------------------------------------
394
+
395
+ def _draw_chi_profile(self):
396
+ """Panel (0,2): Chi from each thermistor vs pressure."""
397
+ ax = self.axes[0, 2]
398
+
399
+ if self._cached_windowed is None:
400
+ ax.text(0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center")
401
+ return
402
+
403
+ P_win, _, chi_arr = self._cached_windowed
404
+ colors_chi = ["C3", "C4"]
405
+ has_data = False
406
+ for i, (name, _) in enumerate(self.therm_fast):
407
+ c = colors_chi[i % len(colors_chi)]
408
+ valid = np.isfinite(chi_arr[i]) & (chi_arr[i] > 0)
409
+ if np.any(valid):
410
+ ax.plot(
411
+ chi_arr[i, valid],
412
+ P_win[valid],
413
+ f"{c}s-",
414
+ markersize=3,
415
+ linewidth=0.8,
416
+ label=name,
417
+ )
418
+ has_data = True
419
+ if has_data:
420
+ ax.set_xscale("log")
421
+ ax.set_xlabel("\u03c7 [K\u00b2/s]")
422
+ ax.set_ylabel("Pressure [dbar]")
423
+ ax.legend(fontsize=6, loc="lower left")
424
+ # chi_method=2 runs compute_chi_window(method=2) -> _iterative_fit,
425
+ # i.e. M2-Iter (NOT the MLE variant).
426
+ selected = {1: "M1", 2: "M2-Iter"}.get(self.chi_method, "M1")
427
+ ax.set_title(f"\u03c7 profile ({selected}, {self.spectrum_model})", fontsize=9)
428
+ ax.grid(True, alpha=0.3, which="both")
429
+ else:
430
+ ax.text(0.5, 0.5, "No valid \u03c7", transform=ax.transAxes, ha="center", va="center")
431
+
432
+ def _draw_spectra(self, sel_spec):
433
+ """Panel (1,1): Epsilon spectra with Nasmyth + K_max."""
434
+ ax = self.axes[1, 1]
435
+
436
+ if self._cached_spec is None:
437
+ ax.text(
438
+ 0.5,
439
+ 0.5,
440
+ "Insufficient data for spectra",
441
+ transform=ax.transAxes,
442
+ ha="center",
443
+ va="center",
444
+ )
445
+ return
446
+
447
+ r = self._cached_spec
448
+ K = r["K"]
449
+ if len(K) == 0:
450
+ ax.text(0.5, 0.5, "No spectra", transform=ax.transAxes, ha="center", va="center")
451
+ return
452
+
453
+ colors = ["C0", "C1", "C4", "C5"]
454
+ for i, (name, _) in enumerate(self.shear):
455
+ if i >= len(r["shear_specs"]):
456
+ break
457
+ c = colors[i % len(colors)]
458
+ ax.loglog(K, r["shear_specs"][i], c, linewidth=0.8, label=name)
459
+ eps_i = r["epsilons"][i]
460
+ meth_i = r["methods"][i] if i < len(r["methods"]) else 0
461
+ chosen = "ISR" if meth_i == 1 else "var"
462
+ e_isr = r["epsilons_isr"][i] if i < len(r.get("epsilons_isr", [])) else np.nan
463
+ e_var = r["epsilons_var"][i] if i < len(r.get("epsilons_var", [])) else np.nan
464
+ if np.isfinite(eps_i):
465
+ isr_mark = "*" if chosen == "ISR" else ""
466
+ var_mark = "*" if chosen == "var" else ""
467
+ label_parts = []
468
+ if np.isfinite(e_isr):
469
+ label_parts.append(f"ISR={e_isr:.1e}{isr_mark}")
470
+ if np.isfinite(e_var):
471
+ label_parts.append(f"var={e_var:.1e}{var_mark}")
472
+ ax.loglog(
473
+ K,
474
+ r["nasmyth_specs"][i],
475
+ c,
476
+ linewidth=0.75,
477
+ linestyle="--",
478
+ alpha=0.9,
479
+ label=f"Nas {', '.join(label_parts)}",
480
+ )
481
+ k_max_i = r["K_maxes"][i]
482
+ if np.isfinite(k_max_i):
483
+ ax.axvline(
484
+ k_max_i,
485
+ color=c,
486
+ linestyle="-.",
487
+ linewidth=0.8,
488
+ alpha=0.6,
489
+ label=f"K_max={k_max_i:.0f} cpm",
490
+ )
491
+ # Filled inverted triangle at K_max on the observed spectrum
492
+ k_idx = np.argmin(np.abs(K - k_max_i))
493
+ ax.plot(
494
+ k_max_i,
495
+ r["shear_specs"][i][k_idx],
496
+ marker="v",
497
+ color=c,
498
+ markersize=8,
499
+ zorder=5,
500
+ )
501
+
502
+ nu = r["nu"]
503
+ for exp in [-9, -8, -7, -6, -5]:
504
+ eps_ref = 10.0**exp
505
+ nas_ref = nasmyth(eps_ref, nu, K)
506
+ ax.loglog(K, nas_ref, "0.8", linewidth=0.3)
507
+ y_val = nas_ref[len(K) // 2]
508
+ if np.isfinite(y_val) and y_val > 0:
509
+ ax.text(K[len(K) // 2], y_val, f"{exp}", fontsize=6, color="0.5", va="bottom")
510
+
511
+ K_AA = self.f_AA / r["W"]
512
+ # Marks the physical anti-aliasing frequency f_AA; the spectral fits
513
+ # stop slightly lower at 0.9*f_AA, so label it to avoid reading the line
514
+ # as the fit cutoff (#13).
515
+ ax.axvline(
516
+ K_AA, color="0.5", linestyle=":", linewidth=0.5, alpha=0.5, label="f_AA"
517
+ )
518
+
519
+ P_lo = float(self.P_fast[sel_spec.start])
520
+ P_hi = float(self.P_fast[min(sel_spec.stop - 1, len(self.P_fast) - 1)])
521
+ ax.set_xlabel("Wavenumber [cpm]")
522
+ ax.set_ylabel("\u03a6(k) [s\u207b\u00b2 cpm\u207b\u00b9]")
523
+ ax.set_xlim(0.5, 300)
524
+ ax.set_ylim(1e-9, 1e0)
525
+ ax.legend(fontsize=5, loc="best", ncol=2)
526
+ ax.set_title(
527
+ f"\u03b5 spectra speed={r['W']:.2f} m/s\nP={P_lo:.1f}\u2013{P_hi:.1f} dbar",
528
+ fontsize=9,
529
+ )
530
+ ax.grid(True, alpha=0.3, which="both")
531
+
532
+ def _draw_chi_spectra(self, sel_spec):
533
+ """Panel (1,2): Chi spectra + all three chi methods."""
534
+ ax = self.axes[1, 2]
535
+ self._cached_chi = None
536
+
537
+ if not self.therm_fast:
538
+ ax.text(
539
+ 0.5,
540
+ 0.5,
541
+ "No thermistor gradient channels",
542
+ transform=ax.transAxes,
543
+ ha="center",
544
+ va="center",
545
+ )
546
+ return
547
+
548
+ n_fast = sel_spec.stop - sel_spec.start
549
+ if n_fast < self.diss_length:
550
+ ax.text(
551
+ 0.5,
552
+ 0.5,
553
+ "Insufficient data for chi spectra",
554
+ transform=ax.transAxes,
555
+ ha="center",
556
+ va="center",
557
+ )
558
+ return
559
+
560
+ eps_for_chi = self._cached_spec["epsilons"] if self._cached_spec else []
561
+ # FM (MAD-based) is the statistic the 1.15 QC limit is defined for
562
+ fom_for_chi = self._cached_spec.get("FMs", []) if self._cached_spec else []
563
+
564
+ self._cached_chi = _compute_chi_spectra(
565
+ self.therm_fast,
566
+ self.diff_gains,
567
+ self.P_fast,
568
+ self.T,
569
+ self.speed_fast,
570
+ self.fs_fast,
571
+ sel_spec,
572
+ self.fft_length,
573
+ self.f_AA,
574
+ eps_for_chi,
575
+ foms=fom_for_chi,
576
+ spectrum_model=self.spectrum_model,
577
+ )
578
+ K, _F, obs_spectra, methods_results, noise_K, mean_speed, _nu = self._cached_chi
579
+
580
+ # Line styles for each method; selected method drawn thicker. chi_method=2
581
+ # is the iterative fit (M2-Iter) — the same curve the chi-profile panel
582
+ # plots — so highlight it, not the separate M2-MLE curve.
583
+ selected_method = {1: "M1", 2: "M2-Iter"}.get(self.chi_method, "M1")
584
+ method_styles = [
585
+ ("M1", "--", 0.9),
586
+ ("M2-MLE", "-.", 0.9),
587
+ ("M2-Iter", ":", 0.9),
588
+ ]
589
+ colors = ["C3", "C1", "C4", "C5"]
590
+ n_probes = len(self.therm_fast)
591
+
592
+ # Plot all lines (no labels yet -- legend order controlled manually)
593
+ obs_lines: list[tuple | None] = []
594
+ method_lines: dict[str, list[tuple | None]] = {m: [] for m, _, _ in method_styles}
595
+ for i, (name, _) in enumerate(self.therm_fast):
596
+ c = colors[i % len(colors)]
597
+ valid = np.isfinite(obs_spectra[i]) & (obs_spectra[i] > 0)
598
+ if np.any(valid):
599
+ (line,) = ax.loglog(K[valid], obs_spectra[i][valid], c, linewidth=0.8)
600
+ obs_lines.append((line, name))
601
+ else:
602
+ obs_lines.append(None)
603
+
604
+ for method_name, ls, alpha in method_styles:
605
+ batch_spec, chi_val, _kB_val = methods_results[method_name][i]
606
+ valid_b = np.isfinite(batch_spec) & (batch_spec > 0)
607
+ lw = 1.5 if method_name == selected_method else 0.75
608
+ if np.any(valid_b):
609
+ chi_str = f"{chi_val:.1e}" if np.isfinite(chi_val) else "N/A"
610
+ label = f"{method_name} \u03c7={chi_str}"
611
+ if method_name == selected_method:
612
+ label = f"*{label}"
613
+ (line,) = ax.loglog(
614
+ K[valid_b],
615
+ batch_spec[valid_b],
616
+ c,
617
+ linewidth=lw,
618
+ linestyle=ls,
619
+ alpha=alpha,
620
+ )
621
+ method_lines[method_name].append((line, label))
622
+ else:
623
+ method_lines[method_name].append(None)
624
+
625
+ valid_n = np.isfinite(noise_K) & (noise_K > 0) & (K > 0)
626
+ noise_line = None
627
+ if np.any(valid_n):
628
+ (nl,) = ax.loglog(K[valid_n], noise_K[valid_n], "0.5", linewidth=0.8, linestyle=":")
629
+ noise_line = (nl, "Noise")
630
+
631
+ K_AA = self.f_AA / mean_speed
632
+ # Marks the physical anti-aliasing frequency f_AA; the spectral fits
633
+ # stop slightly lower at 0.9*f_AA, so label it to avoid reading the line
634
+ # as the fit cutoff (#13).
635
+ ax.axvline(
636
+ K_AA, color="0.5", linestyle=":", linewidth=0.5, alpha=0.5, label="f_AA"
637
+ )
638
+
639
+ # Build legend: left column = probe 0, right column = probe 1.
640
+ # matplotlib ncol fills column-major: entries 0..N/2-1 go in left col,
641
+ # N/2..N-1 in right col. So we build per-column lists then concatenate.
642
+ rows = [obs_lines] + [method_lines[m] for m, _, _ in method_styles]
643
+ columns: list[list] = [[] for _ in range(n_probes)]
644
+ for ci in range(n_probes):
645
+ for row in rows:
646
+ if ci < len(row) and row[ci] is not None:
647
+ columns[ci].append(row[ci])
648
+ else:
649
+ (ph,) = ax.plot([], [], alpha=0)
650
+ columns[ci].append((ph, ""))
651
+ # Noise in the last row of each column
652
+ if ci == 0 and noise_line is not None:
653
+ columns[ci].append(noise_line)
654
+ else:
655
+ (ph,) = ax.plot([], [], alpha=0)
656
+ columns[ci].append((ph, ""))
657
+
658
+ # Pad columns to equal length
659
+ max_len = max(len(c) for c in columns)
660
+ for col_list in columns:
661
+ while len(col_list) < max_len:
662
+ (ph,) = ax.plot([], [], alpha=0)
663
+ col_list.append((ph, ""))
664
+
665
+ handles, labels = [], []
666
+ for col_list in columns:
667
+ for h, lbl in col_list:
668
+ handles.append(h)
669
+ labels.append(lbl)
670
+
671
+ ax.set_xlabel("Wavenumber [cpm]")
672
+ ax.set_ylabel("\u03a6_T(k) [(K/m)\u00b2 cpm\u207b\u00b9]")
673
+ ax.set_xlim(0.5, 300)
674
+ ax.set_ylim(1e-11, None)
675
+ ax.legend(handles, labels, fontsize=5, loc="best", ncol=n_probes)
676
+ ax.set_title(
677
+ f"\u03c7 spectra fit={selected_method} model={self.spectrum_model}\n"
678
+ f"speed={mean_speed:.2f} m/s",
679
+ fontsize=9,
680
+ )
681
+ ax.grid(True, alpha=0.3, which="both")
682
+
683
+
684
+ # ---------------------------------------------------------------------------
685
+ # Entry point
686
+ # ---------------------------------------------------------------------------
687
+
688
+
689
+ def quick_look(
690
+ source,
691
+ fft_length=1024,
692
+ f_AA=98.0,
693
+ goodman=True,
694
+ direction="auto",
695
+ P_min=0.5,
696
+ W_min=0.3,
697
+ min_duration=7.0,
698
+ spec_P_range=None,
699
+ chi_method=1,
700
+ spectrum_model="kraichnan",
701
+ diss_length=None,
702
+ vehicle=None,
703
+ ) -> QuickLookViewer:
704
+ """Open an interactive quick-look viewer for a .p file.
705
+
706
+ Parameters
707
+ ----------
708
+ source : str or Path
709
+ Path to a Rockland .p file.
710
+ fft_length : int
711
+ FFT segment length for spectral estimation (default: 1024).
712
+ f_AA : float
713
+ Anti-aliasing filter cutoff [Hz].
714
+ goodman : bool
715
+ Apply Goodman coherent noise removal to shear spectra.
716
+ direction : str
717
+ Profile direction: 'up' or 'down'.
718
+ P_min : float
719
+ Minimum pressure for profile detection [dbar].
720
+ W_min : float
721
+ Minimum fall rate for profile detection [dbar/s].
722
+ min_duration : float
723
+ Minimum profile duration [s].
724
+ spec_P_range : tuple of (float, float) or None
725
+ Pressure range (P_min, P_max) in dbar for spectral calculations.
726
+ If None, uses the full profile depth range.
727
+ chi_method : int
728
+ Chi method for windowed profile estimates: 1 = from known epsilon,
729
+ 2 = MLE Batchelor fit. All three methods are always shown on the
730
+ chi spectra panel.
731
+ spectrum_model : str
732
+ Theoretical spectrum model: 'batchelor' or 'kraichnan'.
733
+ diss_length : int or None
734
+ Dissipation window length [samples]. Default: 4 * fft_length.
735
+
736
+ Returns
737
+ -------
738
+ QuickLookViewer
739
+ The viewer instance (useful for scripting).
740
+ """
741
+ pf = PFile(source)
742
+ viewer = QuickLookViewer(
743
+ pf,
744
+ fft_length=fft_length,
745
+ f_AA=f_AA,
746
+ goodman=goodman,
747
+ direction=direction,
748
+ P_min=P_min,
749
+ W_min=W_min,
750
+ min_duration=min_duration,
751
+ spec_P_range=spec_P_range,
752
+ chi_method=chi_method,
753
+ spectrum_model=spectrum_model,
754
+ diss_length=diss_length,
755
+ vehicle=vehicle,
756
+ )
757
+ viewer.show()
758
+ return viewer