microstructure-tpw 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
|
@@ -0,0 +1,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()
|