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,1071 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Shared base class for interactive profile viewers (quick_look, diss_look).
3
+
4
+ ProfileViewer handles common setup (channel extraction, profile detection,
5
+ speed interpolation, shear conversion), navigation buttons, and shared
6
+ drawing methods. Subclasses define their panel layout by overriding
7
+ ``_setup_axes`` and ``_draw``.
8
+ """
9
+
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+ from matplotlib.widgets import Button
13
+
14
+ from odas_tpw.rsi.helpers import AC_PATTERN, DT_PATTERN, SH_PATTERN, T_PATTERN
15
+ from odas_tpw.rsi.profile import _smooth_fall_rate, get_profiles
16
+ from odas_tpw.rsi.window import compute_chi_window, compute_eps_window
17
+ from odas_tpw.scor160.despike import despike
18
+ from odas_tpw.scor160.nasmyth import nasmyth
19
+ from odas_tpw.scor160.ocean import visc35
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Shared free functions
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _interp_slow_to_fast(T_slow: np.ndarray, n_fast: int) -> np.ndarray:
27
+ """Linear-interpolate a slow-rate series onto an ``n_fast``-length grid.
28
+
29
+ Used so per-window viscosity / noise can average temperature over the
30
+ window slice (matching the pipeline's per-window ``l3.temp``) instead of the
31
+ whole-cast mean, which biases viscosity-derived epsilon/chi by ~14% with
32
+ depth.
33
+ """
34
+ T_slow = np.asarray(T_slow, dtype=np.float64)
35
+ if T_slow.size == n_fast:
36
+ return T_slow
37
+ if T_slow.size < 2:
38
+ return np.full(n_fast, float(T_slow[0]) if T_slow.size else np.nan)
39
+ return np.asarray(
40
+ np.interp(np.arange(n_fast), np.linspace(0, n_fast - 1, T_slow.size), T_slow),
41
+ dtype=np.float64,
42
+ )
43
+
44
+
45
+ def select_mid_window(
46
+ P_fast: np.ndarray, sel: slice, fft_length: int, diss_length: int | None = None
47
+ ) -> slice:
48
+ """Select the single diss_length window closest to the midpoint pressure.
49
+
50
+ Returns a slice for the selected window, or *sel* unchanged if the
51
+ segment is too short for even one window.
52
+ """
53
+ if diss_length is None:
54
+ diss_length = 4 * fft_length
55
+ n_fast = sel.stop - sel.start
56
+ overlap = diss_length // 2
57
+ step = diss_length - overlap
58
+ n_windows = max(0, 1 + (n_fast - diss_length) // step)
59
+ if n_windows == 0:
60
+ return sel
61
+
62
+ P_mid = float(np.mean(P_fast[sel]))
63
+ best_idx = 0
64
+ best_dist = float("inf")
65
+ for widx in range(n_windows):
66
+ s = sel.start + widx * step
67
+ e = s + diss_length
68
+ if e > sel.stop:
69
+ break
70
+ dist = abs(float(np.mean(P_fast[s:e])) - P_mid)
71
+ if dist < best_dist:
72
+ best_dist = dist
73
+ best_idx = widx
74
+ w_start = sel.start + best_idx * step
75
+ w_end = min(w_start + diss_length, sel.stop)
76
+ return slice(w_start, w_end)
77
+
78
+
79
+ def compute_depth_spectra(
80
+ shear_data,
81
+ accel_data,
82
+ therm_data,
83
+ diff_gains,
84
+ P_fast,
85
+ T_slow,
86
+ speed_fast,
87
+ fs_fast,
88
+ sel,
89
+ fft_length,
90
+ f_AA,
91
+ do_goodman,
92
+ diss_length=None,
93
+ ) -> dict:
94
+ """Compute shear + chi spectra at one depth for spectral panels.
95
+
96
+ Selects a single diss_length window closest to the midpoint pressure,
97
+ matching MATLAB tpw_plot_spectra.
98
+
99
+ Returns dict with shear spectra, Nasmyth fits, chi gradient spectra,
100
+ and both Method 1/Method 2 model fits.
101
+ """
102
+ if diss_length is None:
103
+ diss_length = 4 * fft_length
104
+ n_shear = len(shear_data)
105
+ n_therm = len(therm_data)
106
+ n_freq = fft_length // 2 + 1
107
+
108
+ # Select a single diss_length window closest to the midpoint pressure
109
+ w_sel = select_mid_window(P_fast, sel, fft_length, diss_length)
110
+ n_win = w_sel.stop - w_sel.start
111
+
112
+ # Per-window mean temperature (was the whole-cast mean -> ~14% visc bias).
113
+ mean_T = float(np.mean(_interp_slow_to_fast(T_slow, len(P_fast))[w_sel]))
114
+
115
+ result = {
116
+ "K": np.array([]),
117
+ "F": np.array([]),
118
+ "W": 0.0,
119
+ "nu": 0.0,
120
+ "shear_specs": [],
121
+ "nasmyth_specs": [],
122
+ "epsilons": [],
123
+ "methods": [],
124
+ "K_maxes": [],
125
+ "chi_obs_specs": [],
126
+ "chi_m1_specs": [],
127
+ "chi_m2_specs": [],
128
+ "chi_m1_vals": [],
129
+ "chi_m2_vals": [],
130
+ "noise_K": None,
131
+ }
132
+
133
+ if n_win < diss_length:
134
+ return result
135
+
136
+ # --- Epsilon via shared compute_eps_window ---
137
+ if n_shear > 0:
138
+ sh_seg = np.column_stack([d[w_sel] for _, d in shear_data])
139
+ ac_seg = np.column_stack([d[w_sel] for _, d in accel_data]) if accel_data else None
140
+ er = compute_eps_window(
141
+ sh_seg,
142
+ ac_seg,
143
+ speed_fast[w_sel],
144
+ P_fast[w_sel],
145
+ mean_T,
146
+ fs_fast,
147
+ fft_length,
148
+ f_AA,
149
+ do_goodman,
150
+ )
151
+ result["K"] = er.K
152
+ result["F"] = er.F
153
+ result["W"] = er.W
154
+ result["nu"] = er.nu
155
+ result["shear_specs"] = er.shear_specs
156
+ result["nasmyth_specs"] = er.nasmyth_specs
157
+ result["epsilons"] = list(er.epsilon)
158
+ result["foms"] = list(er.fom)
159
+ result["FMs"] = list(er.FM)
160
+ result["methods"] = list(er.method)
161
+ result["K_maxes"] = list(er.K_max)
162
+ result["epsilons_isr"] = list(er.epsilon_isr)
163
+ result["epsilons_var"] = list(er.epsilon_var)
164
+ else:
165
+ W = float(np.mean(np.abs(speed_fast[w_sel])))
166
+ if W < 0.01:
167
+ W = 0.01
168
+ nu = float(visc35(mean_T))
169
+ F = np.arange(n_freq) * fs_fast / fft_length
170
+ result["K"] = F / W
171
+ result["F"] = F
172
+ result["W"] = W
173
+ result["nu"] = nu
174
+
175
+ # --- Chi via shared compute_chi_window (M1 + M2) ---
176
+ f_AA_chi = 0.9 * f_AA
177
+ W = result["W"] # type: ignore[assignment] # dict has mixed value types
178
+ nu = result["nu"] # type: ignore[assignment]
179
+
180
+ if n_therm > 0:
181
+ therm_segs = [therm_data[ci][1][w_sel] for ci in range(n_therm)]
182
+ eps_arr = np.array(result["epsilons"]) if result["epsilons"] else None
183
+ # The 1.15 QC limit is defined for the MAD-based Lueck FM
184
+ # statistic, not the variance-ratio fom.
185
+ fom_arr = np.array(result.get("FMs", [])) if result.get("FMs") else None
186
+
187
+ # Method 1: chi from epsilon (kB fixed by FOM-filtered mean epsilon)
188
+ cr_m1 = compute_chi_window(
189
+ therm_segs,
190
+ diff_gains,
191
+ W,
192
+ mean_T,
193
+ nu,
194
+ fs_fast,
195
+ fft_length,
196
+ f_AA_chi,
197
+ spectrum_model="kraichnan",
198
+ epsilon=eps_arr,
199
+ fom=fom_arr,
200
+ method=1,
201
+ )
202
+ result["chi_obs_specs"] = cr_m1.grad_specs
203
+ result["chi_m1_specs"] = cr_m1.model_specs
204
+ result["chi_m1_vals"] = list(cr_m1.chi)
205
+ result["noise_K"] = cr_m1.noise_K
206
+
207
+ # Method 2: iterative fit (kB fitted to observed spectrum)
208
+ cr_m2 = compute_chi_window(
209
+ therm_segs,
210
+ diff_gains,
211
+ W,
212
+ mean_T,
213
+ nu,
214
+ fs_fast,
215
+ fft_length,
216
+ f_AA_chi,
217
+ spectrum_model="kraichnan",
218
+ method=2,
219
+ )
220
+ result["chi_m2_specs"] = cr_m2.model_specs
221
+ result["chi_m2_vals"] = list(cr_m2.chi)
222
+
223
+ return result
224
+
225
+
226
+ def compute_windowed_diss(
227
+ shear_data,
228
+ accel_data,
229
+ therm_data,
230
+ diff_gains,
231
+ P_fast,
232
+ T_slow,
233
+ speed_fast,
234
+ fs_fast,
235
+ sel,
236
+ fft_length,
237
+ f_AA,
238
+ do_goodman,
239
+ diss_length=None,
240
+ ) -> dict:
241
+ """Compute windowed epsilon and chi (both Batchelor and Kraichnan) estimates.
242
+
243
+ Returns dict with P_windows, epsilon, FM, fom, mad, K_max_ratio,
244
+ chi_batchelor, chi_kraichnan arrays.
245
+ """
246
+ if diss_length is None:
247
+ diss_length = 4 * fft_length
248
+ overlap = diss_length // 2
249
+ step = diss_length - overlap
250
+
251
+ seg_start = sel.start
252
+ seg_end = sel.stop
253
+ N = seg_end - seg_start
254
+
255
+ n_shear = len(shear_data)
256
+ n_therm = len(therm_data)
257
+ n_windows = max(0, 1 + (N - diss_length) // step)
258
+
259
+ empty = {
260
+ "P_windows": np.array([]),
261
+ "epsilon": np.empty((n_shear, 0)),
262
+ "FM": np.empty((n_shear, 0)),
263
+ "fom": np.empty((n_shear, 0)),
264
+ "mad": np.empty((n_shear, 0)),
265
+ "K_max_ratio": np.empty((n_shear, 0)),
266
+ "chi_batchelor": np.empty((n_therm, 0)),
267
+ "chi_kraichnan": np.empty((n_therm, 0)),
268
+ "chi_fom_batchelor": np.empty((n_therm, 0)),
269
+ "chi_fom_kraichnan": np.empty((n_therm, 0)),
270
+ }
271
+ if n_windows == 0:
272
+ return empty
273
+
274
+ P_windows = np.full(n_windows, np.nan)
275
+ eps_arr = np.full((n_shear, n_windows), np.nan)
276
+ FM_arr = np.full((n_shear, n_windows), np.nan)
277
+ fom_arr = np.full((n_shear, n_windows), np.nan)
278
+ mad_arr = np.full((n_shear, n_windows), np.nan)
279
+ Kmr_arr = np.full((n_shear, n_windows), np.nan)
280
+ chi_batch_arr = np.full((n_therm, n_windows), np.nan)
281
+ chi_kraich_arr = np.full((n_therm, n_windows), np.nan)
282
+ chi_fom_batch_arr = np.full((n_therm, n_windows), np.nan)
283
+ chi_fom_kraich_arr = np.full((n_therm, n_windows), np.nan)
284
+
285
+ T_fast = _interp_slow_to_fast(T_slow, len(P_fast))
286
+ f_AA_chi = 0.9 * f_AA
287
+
288
+ for idx in range(n_windows):
289
+ s = seg_start + idx * step
290
+ e = s + diss_length
291
+ if e > seg_end:
292
+ break
293
+ w_sel = slice(s, e)
294
+ P_windows[idx] = float(np.mean(P_fast[w_sel]))
295
+ # Per-window mean temperature (was the whole-cast mean -> ~14% bias).
296
+ mean_T = float(np.mean(T_fast[w_sel]))
297
+
298
+ # --- Epsilon ---
299
+ if n_shear > 0:
300
+ sh_seg = np.column_stack([d[w_sel] for _, d in shear_data])
301
+ ac_seg = np.column_stack([d[w_sel] for _, d in accel_data]) if accel_data else None
302
+ er = compute_eps_window(
303
+ sh_seg,
304
+ ac_seg,
305
+ speed_fast[w_sel],
306
+ P_fast[w_sel],
307
+ mean_T,
308
+ fs_fast,
309
+ fft_length,
310
+ f_AA,
311
+ do_goodman,
312
+ )
313
+ eps_arr[:, idx] = er.epsilon
314
+ FM_arr[:, idx] = er.FM
315
+ fom_arr[:, idx] = er.fom
316
+ mad_arr[:, idx] = er.mad
317
+ Kmr_arr[:, idx] = er.K_max_ratio
318
+
319
+ # --- Chi (both Batchelor and Kraichnan, per-probe epsilon) ---
320
+ has_eps = n_shear > 0 and np.any(np.isfinite(er.epsilon) & (er.epsilon > 0))
321
+ if n_therm > 0 and has_eps:
322
+ therm_segs = [therm_data[ci][1][w_sel] for ci in range(n_therm)]
323
+ for model, arr, fom_a in [
324
+ ("batchelor", chi_batch_arr, chi_fom_batch_arr),
325
+ ("kraichnan", chi_kraich_arr, chi_fom_kraich_arr),
326
+ ]:
327
+ cr = compute_chi_window(
328
+ therm_segs,
329
+ diff_gains,
330
+ er.W,
331
+ mean_T,
332
+ er.nu,
333
+ fs_fast,
334
+ fft_length,
335
+ f_AA_chi,
336
+ spectrum_model=model,
337
+ epsilon=er.epsilon,
338
+ fom=er.FM,
339
+ method=1,
340
+ )
341
+ arr[:, idx] = cr.chi
342
+ fom_a[:, idx] = cr.fom
343
+
344
+ return {
345
+ "P_windows": P_windows,
346
+ "epsilon": eps_arr,
347
+ "FM": FM_arr,
348
+ "fom": fom_arr,
349
+ "mad": mad_arr,
350
+ "K_max_ratio": Kmr_arr,
351
+ "chi_batchelor": chi_batch_arr,
352
+ "chi_kraichnan": chi_kraich_arr,
353
+ "chi_fom_batchelor": chi_fom_batch_arr,
354
+ "chi_fom_kraichnan": chi_fom_kraich_arr,
355
+ }
356
+
357
+
358
+ def _hp_filter(x, fs, cutoff=1.0):
359
+ """High-pass filter for shear display."""
360
+ from scipy.signal import butter, filtfilt
361
+
362
+ b, a = butter(1, cutoff / (fs / 2), btype="high")
363
+ return filtfilt(b, a, x)
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Base class
368
+ # ---------------------------------------------------------------------------
369
+
370
+
371
+ class ProfileViewer:
372
+ """Base class for interactive profile viewers.
373
+
374
+ Handles channel extraction, profile detection, interpolation, shear
375
+ conversion, navigation buttons, and common drawing methods.
376
+ Subclasses override ``_setup_axes`` and ``_draw`` for their specific layout.
377
+ Subclasses may set ``_nrows``/``_ncols`` to request a larger panel grid.
378
+ """
379
+
380
+ # Panel grid; subclasses may override (e.g. mixing_look uses 3 rows).
381
+ _nrows = 2
382
+ _ncols = 4
383
+
384
+ def __init__(
385
+ self,
386
+ pf,
387
+ fft_length=1024,
388
+ f_AA=98.0,
389
+ goodman=True,
390
+ direction="auto",
391
+ P_min=0.5,
392
+ W_min=0.3,
393
+ min_duration=7.0,
394
+ spec_P_range=None,
395
+ diss_length=None,
396
+ vehicle=None,
397
+ ):
398
+ from odas_tpw.rsi.vehicle import resolve_direction, resolve_tau
399
+
400
+ self.pf = pf
401
+ self.fft_length = fft_length
402
+ self.diss_length = diss_length or 4 * fft_length
403
+ self.f_AA = f_AA
404
+ self.goodman = goodman
405
+ self.spec_P_range = spec_P_range
406
+
407
+ # Resolve vehicle, direction, and tau
408
+ if vehicle is None:
409
+ vehicle = pf.config["instrument_info"].get("vehicle", "").lower()
410
+ direction = resolve_direction(direction, vehicle)
411
+ tau = resolve_tau(vehicle)
412
+
413
+ # Extract channel data
414
+ self.shear = sorted(
415
+ [(n, pf.channels[n]) for n in pf._fast_channels if SH_PATTERN.match(n)],
416
+ key=lambda x: x[0],
417
+ )
418
+ self.accel = sorted(
419
+ [(n, pf.channels[n]) for n in pf._fast_channels if AC_PATTERN.match(n)],
420
+ key=lambda x: x[0],
421
+ )
422
+ self.therm_slow = sorted(
423
+ [(n, pf.channels[n]) for n in pf._slow_channels if T_PATTERN.match(n)],
424
+ key=lambda x: x[0],
425
+ )
426
+ self.therm_fast = sorted(
427
+ [(n, pf.channels[n]) for n in pf._fast_channels if DT_PATTERN.match(n)],
428
+ key=lambda x: x[0],
429
+ )
430
+ self.diff_gains = []
431
+ for name, _ in self.therm_fast:
432
+ ch_cfg: dict = next((ch for ch in pf.config["channels"] if ch.get("name") == name), {})
433
+ self.diff_gains.append(float(ch_cfg.get("diff_gain", "0.94")))
434
+
435
+ # All temperature channels for temperature panel (T1, T2, JAC_T)
436
+ self.temp_channels = []
437
+ for name in ["T1", "T2", "JAC_T"]:
438
+ if name in pf.channels:
439
+ self.temp_channels.append((name, pf.channels[name]))
440
+
441
+ self.P = pf.channels["P"]
442
+ self.T = pf.channels.get("T1", pf.channels.get("T", np.zeros_like(self.P)))
443
+ self.JAC_T = pf.channels.get("JAC_T", None)
444
+ self.t_fast = pf.t_fast
445
+ self.t_slow = pf.t_slow
446
+ self.fs_fast = pf.fs_fast
447
+ self.fs_slow = pf.fs_slow
448
+
449
+ # Detect profiles
450
+ W = _smooth_fall_rate(self.P, self.fs_slow, tau=tau)
451
+ self.W = W
452
+ self.profiles = get_profiles(
453
+ self.P,
454
+ W,
455
+ self.fs_slow,
456
+ P_min=P_min,
457
+ W_min=W_min,
458
+ direction=direction,
459
+ min_duration=min_duration,
460
+ )
461
+ if not self.profiles:
462
+ raise ValueError("No profiles detected in this file")
463
+
464
+ # Interpolate slow -> fast. Clamp speed to the 0.05 m/s cutout (matching
465
+ # the pipeline's speed_min) before the shear/speed**2 division below, so
466
+ # a near-zero fall rate at a profile turnaround can't blow up the shear.
467
+ self.P_fast = np.interp(self.t_fast, self.t_slow, self.P)
468
+ self.speed_fast = np.maximum(
469
+ np.abs(np.interp(self.t_fast, self.t_slow, W)), 0.05
470
+ )
471
+ self.ratio = round(self.fs_fast / self.fs_slow)
472
+
473
+ # Convert shear from piezo output to du/dz by dividing by speed²
474
+ for i, (name, data) in enumerate(self.shear):
475
+ self.shear[i] = (name, data / self.speed_fast**2)
476
+
477
+ self.profile_idx = 0
478
+ self.fig = None
479
+ self._spec_bin_width = None
480
+ # Per-draw caches populated by subclasses' _draw (used by the shared
481
+ # spectra / FM / FOM drawing methods below).
482
+ self._cached_diss: dict | None = None
483
+ self._cached_spec: dict | None = None
484
+
485
+ # ------------------------------------------------------------------
486
+ # Utility methods
487
+ # ------------------------------------------------------------------
488
+
489
+ def _slow_to_fast_slice(self, s_slow, e_slow):
490
+ s_fast = s_slow * self.ratio
491
+ e_fast = min((e_slow + 1) * self.ratio, len(self.t_fast))
492
+ return slice(s_fast, e_fast)
493
+
494
+ def _spec_fast_slice(self, sel_fast):
495
+ if self.spec_P_range is None:
496
+ return sel_fast
497
+ P_min, P_max = self.spec_P_range
498
+ P_seg = self.P_fast[sel_fast]
499
+ mask = (P_seg >= P_min) & (P_seg <= P_max)
500
+ if not np.any(mask):
501
+ return sel_fast
502
+ indices = np.where(mask)[0]
503
+ return slice(sel_fast.start + indices[0], sel_fast.start + indices[-1] + 1)
504
+
505
+ def _spec_bin_dbar(self, s_slow, e_slow):
506
+ sel = self._slow_to_fast_slice(s_slow, e_slow)
507
+ mean_speed = float(np.mean(np.abs(self.speed_fast[sel])))
508
+ if mean_speed < 0.01:
509
+ mean_speed = 0.01
510
+ return self.diss_length / self.fs_fast * mean_speed
511
+
512
+ # ------------------------------------------------------------------
513
+ # Shared drawing methods — take ax as first parameter
514
+ # ------------------------------------------------------------------
515
+
516
+ def _draw_overview(self, ax, s_slow, e_slow):
517
+ """Profile overview: pressure vs time, current profile highlighted."""
518
+ ax.plot(self.t_slow, self.P, "0.6", linewidth=0.5)
519
+ ax.plot(
520
+ self.t_slow[s_slow : e_slow + 1],
521
+ self.P[s_slow : e_slow + 1],
522
+ "C0",
523
+ linewidth=1.5,
524
+ )
525
+ ax.set_ylabel("Pressure [dbar]")
526
+ ax.set_xlabel("Time [s]")
527
+ ax.set_title("Profile overview", fontsize=9)
528
+ ax.grid(True, alpha=0.3)
529
+ for i, (s, e) in enumerate(self.profiles):
530
+ if i != self.profile_idx:
531
+ ax.axvspan(self.t_slow[s], self.t_slow[e], alpha=0.05, color="C0")
532
+
533
+ def _draw_eps_profile(self, ax, P_win, eps_arr):
534
+ """Epsilon from each shear probe vs pressure."""
535
+ if len(P_win) == 0:
536
+ ax.text(0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center")
537
+ return
538
+
539
+ colors = ["C0", "C1", "C4", "C5"]
540
+ has_data = False
541
+ for i, (name, _) in enumerate(self.shear):
542
+ c = colors[i % len(colors)]
543
+ eps = eps_arr[i]
544
+ valid = np.isfinite(eps) & (eps > 0)
545
+ if np.any(valid):
546
+ ax.plot(eps[valid], P_win[valid], f"{c}o-", markersize=3, linewidth=0.8, label=name)
547
+ has_data = True
548
+
549
+ if has_data:
550
+ ax.set_xscale("log")
551
+ ax.set_xlabel("ε [W/kg]")
552
+ ax.set_ylabel("Pressure [dbar]")
553
+ ax.legend(fontsize=6, loc="lower left")
554
+ ax.set_title("ε profile", fontsize=9)
555
+ ax.grid(True, alpha=0.3, which="both")
556
+ else:
557
+ ax.text(0.5, 0.5, "No valid ε", transform=ax.transAxes, ha="center", va="center")
558
+
559
+ def _draw_shear(self, ax, sel_fast):
560
+ """HP-filtered shear signals vs pressure."""
561
+ P_prof = self.P_fast[sel_fast]
562
+
563
+ if not self.shear:
564
+ ax.text(0.5, 0.5, "No shear channels", transform=ax.transAxes, ha="center", va="center")
565
+ return
566
+
567
+ # Identify settled region: where fall rate is within 20% of median
568
+ # (near terminal velocity, excluding initial acceleration transient)
569
+ W_prof = self.speed_fast[sel_fast]
570
+ median_W = np.median(W_prof)
571
+ settled = W_prof >= 0.8 * median_W
572
+
573
+ all_settled_vals = []
574
+ for i, (name, data) in enumerate(self.shear):
575
+ seg = data[sel_fast]
576
+ if len(seg) > 2 * int(self.fs_fast):
577
+ seg_hp = _hp_filter(seg, self.fs_fast, cutoff=1.0)
578
+ else:
579
+ seg_hp = seg - np.mean(seg)
580
+ seg_hp, _, _, _ = despike(seg_hp, self.fs_fast, thresh=8, smooth=0.5)
581
+ offset = i * 0.5
582
+ ax.plot(seg_hp + offset, P_prof, linewidth=0.3, label=name)
583
+ if np.any(settled):
584
+ all_settled_vals.append(seg_hp[settled] + offset)
585
+
586
+ # Zoom x-axis to 5x the settled-region shear range
587
+ if all_settled_vals:
588
+ combined = np.concatenate(all_settled_vals)
589
+ x_lo = np.percentile(combined, 1)
590
+ x_hi = np.percentile(combined, 99)
591
+ half_range = (x_hi - x_lo) / 2
592
+ center = (x_lo + x_hi) / 2
593
+ half_range = max(half_range * 5, 0.05)
594
+ ax.set_xlim(center - half_range, center + half_range)
595
+
596
+ ax.set_xlabel("Shear [s⁻¹] (HP, offset)")
597
+ ax.legend(fontsize=7, loc="lower right")
598
+ ax.set_title("Shear probes", fontsize=9)
599
+ ax.grid(True, alpha=0.3)
600
+
601
+ def _draw_eps_spectra(self, ax, sel_spec, cached_spec):
602
+ """Shear wavenumber spectra with Nasmyth fits + K_max markers."""
603
+ r = cached_spec
604
+ K = r["K"]
605
+ if len(K) == 0:
606
+ ax.text(0.5, 0.5, "No spectra", transform=ax.transAxes, ha="center", va="center")
607
+ return
608
+
609
+ colors = ["C0", "C1", "C4", "C5"]
610
+ for i, (name, _) in enumerate(self.shear):
611
+ if i >= len(r["shear_specs"]):
612
+ break
613
+ c = colors[i % len(colors)]
614
+ spec = r["shear_specs"][i]
615
+ ax.loglog(K, spec, c, linewidth=0.8, label=name)
616
+ nas = r["nasmyth_specs"][i]
617
+ eps_i = r["epsilons"][i]
618
+ meth_i = r["methods"][i] if i < len(r["methods"]) else 0
619
+ chosen = "ISR" if meth_i == 1 else "var"
620
+ e_isr = r["epsilons_isr"][i] if i < len(r.get("epsilons_isr", [])) else np.nan
621
+ e_var = r["epsilons_var"][i] if i < len(r.get("epsilons_var", [])) else np.nan
622
+ if np.isfinite(eps_i):
623
+ isr_mark = "*" if chosen == "ISR" else ""
624
+ var_mark = "*" if chosen == "var" else ""
625
+ label_parts = []
626
+ if np.isfinite(e_isr):
627
+ label_parts.append(f"ISR={e_isr:.1e}{isr_mark}")
628
+ if np.isfinite(e_var):
629
+ label_parts.append(f"var={e_var:.1e}{var_mark}")
630
+ ax.loglog(
631
+ K,
632
+ nas,
633
+ c,
634
+ linewidth=0.7,
635
+ linestyle="--",
636
+ alpha=0.8,
637
+ label=f"Nas {', '.join(label_parts)}",
638
+ )
639
+ k_max_i = r["K_maxes"][i]
640
+ if np.isfinite(k_max_i):
641
+ k_idx = np.argmin(np.abs(K - k_max_i))
642
+ ax.plot(k_max_i, spec[k_idx], marker="v", color=c, markersize=7, zorder=5)
643
+
644
+ # Reference Nasmyth lines
645
+ nu = r["nu"]
646
+ for exp in [-9, -8, -7, -6, -5]:
647
+ nas_ref = nasmyth(10.0**exp, nu, K)
648
+ ax.loglog(K, nas_ref, "0.8", linewidth=0.3)
649
+
650
+ # AA line
651
+ K_AA = self.f_AA / r["W"]
652
+ ax.axvline(
653
+ K_AA, color="0.5", linestyle=":", linewidth=0.5, alpha=0.5, label="f_AA"
654
+ )
655
+
656
+ # Dynamic y-axis
657
+ x_lo, x_hi = 0.5, 300
658
+ in_range = (x_lo <= K) & (x_hi >= K)
659
+ all_vals = []
660
+ for spec in r["shear_specs"]:
661
+ pos = spec[in_range & (spec > 0)]
662
+ if len(pos):
663
+ all_vals.append(pos)
664
+ for nas in r["nasmyth_specs"]:
665
+ pos = nas[in_range & (nas > 0)]
666
+ if len(pos):
667
+ all_vals.append(pos)
668
+ if all_vals:
669
+ combined = np.concatenate(all_vals)
670
+ y_hi = 10 ** (np.ceil(np.log10(np.max(combined))) + 1)
671
+ y_lo = 10 ** np.floor(np.log10(np.min(combined)))
672
+ else:
673
+ y_hi, y_lo = 1e0, 1e-9
674
+
675
+ P_lo = float(self.P_fast[sel_spec.start])
676
+ P_hi = float(self.P_fast[min(sel_spec.stop - 1, len(self.P_fast) - 1)])
677
+ ax.set_xlabel("Wavenumber [cpm]")
678
+ ax.set_ylabel("Φ_sh [s⁻² cpm⁻¹]")
679
+ ax.set_xlim(x_lo, x_hi)
680
+ ax.set_ylim(y_lo, y_hi)
681
+ ax.legend(fontsize=5, loc="lower left")
682
+ ax.set_title(
683
+ f"ε spectra speed={r['W']:.2f} m/s\nP={P_lo:.1f}–{P_hi:.1f} dbar", # noqa: RUF001
684
+ fontsize=9,
685
+ )
686
+ ax.grid(True, alpha=0.3, which="both")
687
+
688
+ def _draw_temps(self, ax, s_slow, e_slow):
689
+ """T1, T2, JAC_T vs pressure."""
690
+ P_prof = self.P[s_slow : e_slow + 1]
691
+
692
+ if not self.temp_channels:
693
+ ax.text(
694
+ 0.5,
695
+ 0.5,
696
+ "No temperature channels",
697
+ transform=ax.transAxes,
698
+ ha="center",
699
+ va="center",
700
+ )
701
+ return
702
+
703
+ colors = {"T1": "C3", "T2": "C1", "JAC_T": "C9"}
704
+ styles = {"T1": "-", "T2": "-", "JAC_T": "--"}
705
+ for name, data in self.temp_channels:
706
+ c = colors.get(name, "C4")
707
+ ls = styles.get(name, "-")
708
+ ax.plot(data[s_slow : e_slow + 1], P_prof, c, linewidth=0.8, linestyle=ls, label=name)
709
+
710
+ ax.set_xlabel("Temperature [°C]")
711
+ ax.legend(fontsize=7, loc="lower left")
712
+ ax.set_title("T1, T2, JAC_T", fontsize=9)
713
+ ax.grid(True, alpha=0.3)
714
+
715
+ def _draw_fall_rate(self, ax, s_slow, e_slow):
716
+ """Fall rate vs pressure."""
717
+ P_prof = self.P[s_slow : e_slow + 1]
718
+ W_prof = self.W[s_slow : e_slow + 1]
719
+
720
+ ax.plot(W_prof, P_prof, "C2", linewidth=0.8, label="dP/dt")
721
+ ax.set_xlabel("Fall rate [dbar/s]")
722
+ ax.set_ylabel("Pressure [dbar]")
723
+ ax.legend(fontsize=7, loc="lower left")
724
+ ax.set_title("Fall rate", fontsize=9)
725
+ ax.grid(True, alpha=0.3)
726
+
727
+ # ------------------------------------------------------------------
728
+ # Shared FM / chi-spectra / chi-FOM panels (need self._cached_diss /
729
+ # self._cached_spec, populated by the subclass _draw)
730
+ # ------------------------------------------------------------------
731
+
732
+ def _draw_fm_profile(self, ax):
733
+ """Lueck (2022) FM figure of merit vs pressure."""
734
+ d = self._cached_diss
735
+ assert d is not None
736
+ P = d["P_windows"]
737
+
738
+ if len(P) == 0:
739
+ ax.text(0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center")
740
+ return
741
+
742
+ colors = ["C0", "C1", "C4", "C5"]
743
+ has_data = False
744
+ pct1_lines = []
745
+ pct115_lines = []
746
+ for i, (name, _) in enumerate(self.shear):
747
+ c = colors[i % len(colors)]
748
+ fm = d["FM"][i]
749
+ valid = np.isfinite(fm)
750
+ if np.any(valid):
751
+ fm_valid = fm[valid]
752
+ pct1 = 100.0 * np.sum(fm_valid > 1.0) / len(fm_valid)
753
+ pct115 = 100.0 * np.sum(fm_valid > 1.15) / len(fm_valid)
754
+ pct1_lines.append(f"{name}:{pct1:.0f}%")
755
+ pct115_lines.append(f"{name}:{pct115:.0f}%")
756
+ ax.plot(fm_valid, P[valid], f"{c}o-", markersize=3, linewidth=0.8, label=name)
757
+ has_data = True
758
+
759
+ if has_data:
760
+ ax.axvline(
761
+ 1.0,
762
+ color="k",
763
+ linestyle="-",
764
+ linewidth=1.0,
765
+ alpha=0.7,
766
+ label=f"FM>1: {', '.join(pct1_lines)}",
767
+ )
768
+ ax.axvline(
769
+ 1.15,
770
+ color="0.5",
771
+ linestyle="--",
772
+ linewidth=0.7,
773
+ alpha=0.5,
774
+ label=f"FM>1.15: {', '.join(pct115_lines)}",
775
+ )
776
+ xlim = ax.get_xlim()
777
+ ax.axvspan(0, 1.0, color="green", alpha=0.05, zorder=0)
778
+ ax.axvspan(1.0, max(xlim[1], 3), color="red", alpha=0.05, zorder=0)
779
+
780
+ ax.set_xlabel("FM")
781
+ ax.set_ylabel("Pressure [dbar]")
782
+ ax.legend(fontsize=6, loc="lower right")
783
+ ax.set_title("Lueck FM (2022)", fontsize=9)
784
+ ax.grid(True, alpha=0.3)
785
+ ax.set_xlim(0, max(xlim[1], 2))
786
+ else:
787
+ ax.text(0.5, 0.5, "No valid FM", transform=ax.transAxes, ha="center", va="center")
788
+
789
+ def _draw_chi_fom_profile(self, ax):
790
+ """Chi figure of merit vs pressure (Batchelor & Kraichnan)."""
791
+ d = self._cached_diss
792
+ assert d is not None
793
+ P = d["P_windows"]
794
+
795
+ if len(P) == 0:
796
+ ax.text(0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center")
797
+ return
798
+
799
+ colors = ["C3", "C1", "C4", "C5"]
800
+ has_data = False
801
+ for i, (name, _) in enumerate(self.therm_fast):
802
+ c = colors[i % len(colors)]
803
+
804
+ fom_b = d["chi_fom_batchelor"][i]
805
+ valid_b = np.isfinite(fom_b)
806
+ if np.any(valid_b):
807
+ ax.plot(
808
+ fom_b[valid_b],
809
+ P[valid_b],
810
+ color=c,
811
+ linestyle="--",
812
+ marker="^",
813
+ markersize=2.5,
814
+ linewidth=0.8,
815
+ label=f"{name} Batch.",
816
+ )
817
+ has_data = True
818
+
819
+ fom_k = d["chi_fom_kraichnan"][i]
820
+ valid_k = np.isfinite(fom_k)
821
+ if np.any(valid_k):
822
+ ax.plot(
823
+ fom_k[valid_k],
824
+ P[valid_k],
825
+ color=c,
826
+ linestyle="-",
827
+ marker="o",
828
+ markersize=2.5,
829
+ linewidth=0.8,
830
+ label=f"{name} Kraich.",
831
+ )
832
+ has_data = True
833
+
834
+ if has_data:
835
+ ax.axvline(1.0, color="k", linestyle="-", linewidth=1.0, alpha=0.7, label="FOM = 1")
836
+ ax.set_xlabel("χ FOM (obs/model)")
837
+ ax.set_ylabel("Pressure [dbar]")
838
+ ax.legend(fontsize=5, loc="lower right")
839
+ ax.set_title("χ FOM (Batch. & Kraich.)", fontsize=9)
840
+ ax.grid(True, alpha=0.3)
841
+ else:
842
+ ax.text(
843
+ 0.5, 0.5, "No valid χ FOM", transform=ax.transAxes, ha="center", va="center"
844
+ )
845
+
846
+ def _draw_chi_m1m2_spectra(self, ax, sel_spec):
847
+ """Chi spectra -- Method 1 (from epsilon) vs Method 2 (iterative fit)."""
848
+ n_fast = sel_spec.stop - sel_spec.start
849
+ if n_fast < self.diss_length:
850
+ ax.text(0.5, 0.5, "Insufficient data", transform=ax.transAxes, ha="center", va="center")
851
+ return
852
+
853
+ r = self._cached_spec
854
+ assert r is not None
855
+ K_chi = r["F"] / r["W"]
856
+
857
+ if not r["chi_obs_specs"]:
858
+ ax.text(0.5, 0.5, "No χ spectra", transform=ax.transAxes, ha="center", va="center")
859
+ return
860
+
861
+ colors_chi = ["C3", "C1"]
862
+ for i, (name, _) in enumerate(self.therm_fast):
863
+ if i >= len(r["chi_obs_specs"]):
864
+ break
865
+ c = colors_chi[i % len(colors_chi)]
866
+
867
+ obs = r["chi_obs_specs"][i]
868
+ valid = np.isfinite(obs) & (obs > 0) & (K_chi > 0)
869
+ if np.any(valid):
870
+ ax.loglog(K_chi[valid], obs[valid], c, linewidth=0.8, alpha=0.6, label=name)
871
+
872
+ m1_spec = r["chi_m1_specs"][i]
873
+ chi_m1 = r["chi_m1_vals"][i]
874
+ valid_m1 = np.isfinite(m1_spec) & (m1_spec > 0) & (K_chi > 0)
875
+ if np.any(valid_m1) and np.isfinite(chi_m1):
876
+ ax.loglog(
877
+ K_chi[valid_m1],
878
+ m1_spec[valid_m1],
879
+ c,
880
+ linewidth=1.2,
881
+ linestyle="--",
882
+ alpha=0.9,
883
+ label=f"M1 χ={chi_m1:.1e}",
884
+ )
885
+
886
+ m2_spec = r["chi_m2_specs"][i]
887
+ chi_m2 = r["chi_m2_vals"][i]
888
+ valid_m2 = np.isfinite(m2_spec) & (m2_spec > 0) & (K_chi > 0)
889
+ if np.any(valid_m2) and np.isfinite(chi_m2):
890
+ ax.loglog(
891
+ K_chi[valid_m2],
892
+ m2_spec[valid_m2],
893
+ c,
894
+ linewidth=1.2,
895
+ linestyle="-.",
896
+ alpha=0.9,
897
+ label=f"M2 χ={chi_m2:.1e}",
898
+ )
899
+
900
+ noise = r["noise_K"]
901
+ if noise is not None:
902
+ valid_n = np.isfinite(noise) & (noise > 0) & (K_chi > 0)
903
+ if np.any(valid_n):
904
+ ax.loglog(
905
+ K_chi[valid_n],
906
+ noise[valid_n],
907
+ "0.5",
908
+ linewidth=0.6,
909
+ linestyle=":",
910
+ label="Noise",
911
+ )
912
+
913
+ K_AA = self.f_AA / r["W"]
914
+ ax.axvline(
915
+ K_AA, color="0.5", linestyle=":", linewidth=0.5, alpha=0.5, label="f_AA"
916
+ )
917
+
918
+ P_lo = float(self.P_fast[sel_spec.start])
919
+ P_hi = float(self.P_fast[min(sel_spec.stop - 1, len(self.P_fast) - 1)])
920
+ x_lo, x_hi = 0.5, 300
921
+ in_range = (K_chi >= x_lo) & (K_chi <= x_hi)
922
+ all_vals = []
923
+ for spec_list in [r["chi_obs_specs"], r["chi_m1_specs"], r["chi_m2_specs"]]:
924
+ for s in spec_list:
925
+ pos = s[in_range & (s > 0) & np.isfinite(s)]
926
+ if len(pos):
927
+ all_vals.append(pos)
928
+ if all_vals:
929
+ combined = np.concatenate(all_vals)
930
+ y_hi = 10 ** (np.ceil(np.log10(np.max(combined))) + 1)
931
+ else:
932
+ y_hi = 1e-2
933
+ if noise is not None:
934
+ noise_in = noise[in_range & (noise > 0) & np.isfinite(noise)]
935
+ y_lo = 10 ** (np.floor(np.log10(np.min(noise_in))) - 1) if len(noise_in) else 1e-11
936
+ else:
937
+ y_lo = 1e-11
938
+
939
+ ax.set_xlabel("Wavenumber [cpm]")
940
+ ax.set_ylabel("Φ_T [(K/m)² cpm⁻¹]")
941
+ ax.set_xlim(x_lo, x_hi)
942
+ ax.set_ylim(y_lo, y_hi)
943
+ ax.legend(fontsize=5, loc="lower left")
944
+ ax.set_title(
945
+ f"χ spectra (Kraichnan) P={P_lo:.1f}–{P_hi:.1f}\n" # noqa: RUF001
946
+ f"(-- M1 from ε, -· M2 iter)",
947
+ fontsize=9,
948
+ )
949
+ ax.grid(True, alpha=0.3, which="both")
950
+
951
+ # ------------------------------------------------------------------
952
+ # Figure setup and navigation
953
+ # ------------------------------------------------------------------
954
+
955
+ def _setup_axes(self):
956
+ """Configure axis linkage. Override in subclass."""
957
+
958
+ def _draw(self):
959
+ """Draw all panels. Override in subclass."""
960
+
961
+ def _finish_draw(self, s_slow, e_slow, pressure_axes):
962
+ """Shared title and green-band logic, called at end of _draw."""
963
+ if self.spec_P_range is not None:
964
+ sp_lo, sp_hi = self.spec_P_range
965
+ for ax in pressure_axes:
966
+ ax.axhspan(sp_lo, sp_hi, color="green", alpha=0.15, zorder=0)
967
+
968
+ P_lo, P_hi = self.P[s_slow], self.P[e_slow]
969
+ title = (
970
+ f"{self.pf.filepath.name} — "
971
+ f"Profile {self.profile_idx + 1}/{len(self.profiles)} — "
972
+ f"P: {P_lo:.1f}–{P_hi:.1f} dbar" # noqa: RUF001
973
+ )
974
+ if self.spec_P_range is not None:
975
+ sp_lo, sp_hi = self.spec_P_range
976
+ title += f" [spectra: {sp_lo:.1f}–{sp_hi:.1f} dbar]" # noqa: RUF001
977
+ assert self.fig is not None
978
+ self.fig.suptitle(title, fontsize=11, fontweight="bold")
979
+ self.fig.canvas.draw_idle()
980
+
981
+ def show(self) -> None:
982
+ hspace = 0.35
983
+ self.fig, self.axes = plt.subplots(
984
+ self._nrows,
985
+ self._ncols,
986
+ figsize=(6.0 * self._ncols, 4.5 * self._nrows),
987
+ gridspec_kw={"hspace": hspace, "wspace": 0.32},
988
+ squeeze=False,
989
+ )
990
+ left, right, bottom, top = 0.04, 0.98, 0.08, 0.92
991
+ self.fig.subplots_adjust(bottom=bottom, top=top, left=left, right=right)
992
+
993
+ self._setup_axes()
994
+ self._add_nav_diamond(left, right, bottom, top, hspace)
995
+
996
+ self._draw()
997
+ plt.show()
998
+
999
+ def _add_nav_diamond(self, left, right, bottom, top, hspace) -> None:
1000
+ """Place a textless 4-arrow navigation diamond in a panel gap.
1001
+
1002
+ Left/right (◀ ▶) step the profile; up/down (▲ ▼) move the spectral
1003
+ depth window. Horizontally it sits between the middle two columns;
1004
+ vertically it is centered in the gap between the top two rows, so it
1005
+ lands in a clean inter-row gap rather than across a panel row. For a
1006
+ two-row grid (quick_look/diss_look) that gap is the figure center.
1007
+ """
1008
+ assert self.fig is not None
1009
+ cx = (left + right) / 2.0
1010
+ # Center of the gap between row 1 and row 2 from the subplot geometry.
1011
+ row_h = (top - bottom) / (self._nrows + (self._nrows - 1) * hspace)
1012
+ cy = top - row_h - (hspace * row_h) / 2.0
1013
+ bw, bh = 0.024, 0.032 # button size [figure fraction]
1014
+ s = 0.040 # diamond radius
1015
+ ax_up = self.fig.add_axes((cx - bw / 2, cy + s - bh / 2, bw, bh))
1016
+ ax_dn = self.fig.add_axes((cx - bw / 2, cy - s - bh / 2, bw, bh))
1017
+ ax_left = self.fig.add_axes((cx - s - bw / 2, cy - bh / 2, bw, bh))
1018
+ ax_right = self.fig.add_axes((cx + s - bw / 2, cy - bh / 2, bw, bh))
1019
+ self.btn_spec_up = Button(ax_up, "▲")
1020
+ self.btn_spec_dn = Button(ax_dn, "▼")
1021
+ self.btn_prev = Button(ax_left, "◀")
1022
+ self.btn_next = Button(ax_right, "▶")
1023
+ for b in (self.btn_spec_up, self.btn_spec_dn, self.btn_prev, self.btn_next):
1024
+ b.label.set_fontsize(13)
1025
+ self.btn_spec_up.on_clicked(self._on_spec_up)
1026
+ self.btn_spec_dn.on_clicked(self._on_spec_dn)
1027
+ self.btn_prev.on_clicked(self._on_prev)
1028
+ self.btn_next.on_clicked(self._on_next)
1029
+
1030
+ def _on_prev(self, event):
1031
+ if self.profile_idx > 0:
1032
+ self.profile_idx -= 1
1033
+ self._draw()
1034
+
1035
+ def _on_next(self, event):
1036
+ if self.profile_idx < len(self.profiles) - 1:
1037
+ self.profile_idx += 1
1038
+ self._draw()
1039
+
1040
+ def _on_spec_up(self, event):
1041
+ s_slow, e_slow = self.profiles[self.profile_idx]
1042
+ P_top = float(min(self.P[s_slow], self.P[e_slow]))
1043
+ bw = self._spec_bin_width or 1.0
1044
+ if self.spec_P_range is None:
1045
+ self.spec_P_range = (P_top, P_top + bw)
1046
+ else:
1047
+ step = self.spec_P_range[1] - self.spec_P_range[0]
1048
+ new_lo = self.spec_P_range[0] - step
1049
+ new_hi = self.spec_P_range[1] - step
1050
+ if new_lo < P_top:
1051
+ new_lo = P_top
1052
+ new_hi = P_top + step
1053
+ self.spec_P_range = (new_lo, new_hi)
1054
+ self._draw()
1055
+
1056
+ def _on_spec_dn(self, event):
1057
+ s_slow, e_slow = self.profiles[self.profile_idx]
1058
+ P_bot = float(max(self.P[s_slow], self.P[e_slow]))
1059
+ P_top = float(min(self.P[s_slow], self.P[e_slow]))
1060
+ bw = self._spec_bin_width or 1.0
1061
+ if self.spec_P_range is None:
1062
+ self.spec_P_range = (P_top, P_top + bw)
1063
+ else:
1064
+ step = self.spec_P_range[1] - self.spec_P_range[0]
1065
+ new_lo = self.spec_P_range[0] + step
1066
+ new_hi = self.spec_P_range[1] + step
1067
+ if new_hi > P_bot:
1068
+ new_hi = P_bot
1069
+ new_lo = P_bot - step
1070
+ self.spec_P_range = (new_lo, new_hi)
1071
+ self._draw()