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,45 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """``perturb-diag epsilon`` — interactive dissipation inspector.
3
+
4
+ Overview: pcolor of the combined mean and per-probe epsilon over cast x depth.
5
+ Click a cell (or arrow-key around) to drill into that dissipation estimate's
6
+ shear wavenumber spectra (with Nasmyth fits and K_max), the profile's
7
+ dissipation diagnostics, and — from the profiles product — a row of raw
8
+ instrument diagnostics (inclinometer, accelerometer, shear probe, temperature
9
+ gradient) vs depth.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+
16
+ from odas_tpw.perturb.diag import _common, render
17
+ from odas_tpw.perturb.diag.data import load_profile_file
18
+
19
+ # Overview panels: (combo variable, mathtext title). Matches the Matlab
20
+ # diss_inspector's epsilonMean / e_1 / e_2 layout.
21
+ _FIELDS: list[tuple[str, str]] = [
22
+ ("epsilonMean", r"$\langle\epsilon\rangle$"),
23
+ ("e_1", r"$\epsilon_1$"),
24
+ ("e_2", r"$\epsilon_2$"),
25
+ ]
26
+
27
+
28
+ def add_arguments(p: argparse.ArgumentParser) -> None:
29
+ """Register CLI flags for the epsilon diagnostics subcommand on *p*."""
30
+ _common.add_common_args(p)
31
+
32
+
33
+ def run(args: argparse.Namespace) -> str:
34
+ """Launch the interactive inspector (or write a snapshot with --out)."""
35
+ return _common.build_and_run(
36
+ args,
37
+ combo_stage="diss_combo",
38
+ profile_stage="diss",
39
+ fields=_FIELDS,
40
+ qc_var="qc_drop_epsilon",
41
+ loader=load_profile_file,
42
+ spectra_fn=render.draw_eps_spectra,
43
+ strip_fn=render.draw_diss_strip,
44
+ cbar_label=r"$\epsilon$ (W kg$^{-1}$)",
45
+ )
@@ -0,0 +1,342 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """The single-figure interactive engine behind ``perturb-diag``.
3
+
4
+ One matplotlib window holds a time x depth pcolor overview (top) and the
5
+ per-cell drill-down (bottom: spectra + a diagnostic strip). Clicking an
6
+ overview cell — or moving with the arrow keys — selects a ``(profile, depth)``
7
+ cell; the drill-down panels redraw in place. This avoids the multiple-figure
8
+ juggling that is awkward in matplotlib: there is exactly one figure and one set
9
+ of event handlers for its lifetime.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ from collections.abc import Callable
16
+
17
+ import numpy as np
18
+
19
+ from odas_tpw.perturb.diag import render
20
+ from odas_tpw.perturb.diag.data import Cell, CellSource, OverviewData
21
+ from odas_tpw.perturb.plot import layout
22
+
23
+ # Signature of a drill-down renderer: (axes-or-ax, cell) -> optional depth range.
24
+ SpectraFn = Callable[[object, Cell], None]
25
+ StripFn = Callable[[list, Cell], "tuple[float, float]"]
26
+ # Raw-diagnostics renderer: (axes, cell) -> None (shares the strip's depth axis).
27
+ DiagFn = Callable[[list, Cell], None]
28
+
29
+
30
+ class DiagInspector:
31
+ """Interactive overview + click-driven per-cell diagnostics in one figure."""
32
+
33
+ def __init__(
34
+ self,
35
+ data: OverviewData,
36
+ source: CellSource,
37
+ *,
38
+ field_specs: list[tuple[str, str]],
39
+ spectra_fn: SpectraFn,
40
+ strip_fn: StripFn,
41
+ n_strip: int = 5,
42
+ diag_fn: DiagFn | None = None,
43
+ n_diag: int = 0,
44
+ title: str = "",
45
+ cbar_label: str = r"$\epsilon$ (W kg$^{-1}$)",
46
+ clim: tuple[float, float] | None = None,
47
+ per_field_clim: bool = False,
48
+ gap_seconds: float = 600.0,
49
+ figsize: tuple[float, float] = (16.0, 9.0),
50
+ ) -> None:
51
+ self.data = data
52
+ self.source = source
53
+ self.field_specs = field_specs
54
+ self.spectra_fn = spectra_fn
55
+ self.strip_fn = strip_fn
56
+ self.diag_fn = diag_fn
57
+ self.n_diag = int(n_diag)
58
+ self.title = title
59
+ self.cbar_label = cbar_label
60
+ self.per_field_clim = per_field_clim
61
+ self.gap_seconds = gap_seconds
62
+
63
+ self.iProfile = 0
64
+ self.iDepth = 0
65
+ self.iField = 0 # which overview panel was last clicked (mixing dispatch)
66
+ self.ax_diag: list = []
67
+
68
+ self._build(n_strip, clim, figsize)
69
+
70
+ # ------------------------------------------------------------------ build
71
+ def _build(self, n_strip, clim, figsize) -> None:
72
+ import cmocean
73
+ import matplotlib.pyplot as plt
74
+ from matplotlib.ticker import MaxNLocator
75
+
76
+ self.cast_x, self.segments, *_ = layout.compute_layout(
77
+ self.data.t, gap_seconds=self.gap_seconds
78
+ )
79
+
80
+ fig = plt.figure(figsize=figsize, constrained_layout=True)
81
+ self.fig = fig
82
+ n_ov = len(self.field_specs)
83
+ has_diag = self.diag_fn is not None and self.n_diag > 0
84
+ # Three rows when the raw-diagnostics strip is present: overview,
85
+ # spectra+diss-strip, raw-diagnostics. Otherwise the original two.
86
+ if has_diag:
87
+ gs = fig.add_gridspec(3, 1, height_ratios=[1.0, 1.0, 0.85])
88
+ else:
89
+ gs = fig.add_gridspec(2, 1, height_ratios=[1.0, 1.05])
90
+ gs_top = gs[0].subgridspec(1, n_ov, wspace=0.06)
91
+ gs_mid = gs[1].subgridspec(1, 2 + n_strip, wspace=0.30)
92
+
93
+ # Overview panels share the cast (x) and depth (y) axes.
94
+ self.ax_ov: list = []
95
+ for i in range(n_ov):
96
+ share = None if i == 0 else self.ax_ov[0]
97
+ ax = fig.add_subplot(gs_top[0, i], sharex=share, sharey=share)
98
+ self.ax_ov.append(ax)
99
+ self.ax_spec = fig.add_subplot(gs_mid[0, 0:2])
100
+ self.ax_strip = [fig.add_subplot(gs_mid[0, 2 + i]) for i in range(n_strip)]
101
+ # All depth axes — the overview panels, the diss strip, and the raw
102
+ # diagnostics — share one depth (y) axis, so panning/zooming depth on any
103
+ # of them moves them all together and they stay vertically aligned. The
104
+ # spectra panel (wavenumber x PSD) is deliberately left independent.
105
+ for ax in self.ax_strip:
106
+ ax.sharey(self.ax_ov[0])
107
+ self.ax_diag = []
108
+ if has_diag:
109
+ gs_diag = gs[2].subgridspec(1, self.n_diag, wspace=0.30)
110
+ self.ax_diag = [
111
+ fig.add_subplot(gs_diag[0, i]) for i in range(self.n_diag)
112
+ ]
113
+ for ax in self.ax_diag:
114
+ ax.sharey(self.ax_ov[0])
115
+ self.ax_diag[0].set_ylabel("Depth (m)")
116
+
117
+ cmap = cmocean.cm.thermal
118
+ # One shared log color scale across panels (epsilon/chi: same units)
119
+ # unless per_field_clim (mixing: K_rho / K_T / Gamma differ in units and
120
+ # magnitude), in which case each panel gets its own scale + colorbar.
121
+ shared_norm = None
122
+ if not self.per_field_clim:
123
+ stacked = np.concatenate([f.ravel() for f in self.data.fields.values()])
124
+ if clim is not None:
125
+ vmin, vmax = 10.0 ** clim[0], 10.0 ** clim[1]
126
+ else:
127
+ vmin, vmax = layout.quantile_limits(stacked)
128
+ shared_norm = _safe_lognorm(vmin, vmax)
129
+
130
+ self._xhair: list = []
131
+ self._yhair: list = []
132
+ pcm_shared = None
133
+ for ax, (name, label) in zip(self.ax_ov, self.field_specs):
134
+ z = self.data.fields[name]
135
+ if self.per_field_clim:
136
+ vmin, vmax = layout.quantile_limits(z.ravel())
137
+ norm = _safe_lognorm(vmin, vmax)
138
+ else:
139
+ norm = shared_norm
140
+ if norm is not None:
141
+ pcm = render.draw_overview_mesh(
142
+ ax, self.cast_x, self.segments, self.data.bin, z, cmap, norm
143
+ )
144
+ if self.per_field_clim:
145
+ fig.colorbar(pcm, ax=ax, location="right", shrink=0.9, pad=0.02)
146
+ else:
147
+ pcm_shared = pcm
148
+ ax.set_title(label)
149
+ ax.set_xlabel("Cast")
150
+ # Crosshair (hidden until a cell is selected).
151
+ self._xhair.append(
152
+ ax.axvline(self.cast_x[0], color="w", lw=0.8, visible=False)
153
+ )
154
+ self._yhair.append(
155
+ ax.axhline(self.data.bin[0], color="w", lw=0.8, visible=False)
156
+ )
157
+ self.ax_ov[0].set_ylabel("Depth (m)")
158
+ # Cast number is an integer index; force whole-number x ticks (the shared
159
+ # x-axis can otherwise land on fractions, e.g. 2.5, for a narrow section).
160
+ for ax in self.ax_ov:
161
+ ax.xaxis.set_major_locator(MaxNLocator(integer=True))
162
+ if len(self.cast_x):
163
+ self.ax_ov[0].set_xlim(self.cast_x[0] - 0.5, self.cast_x[-1] + 0.5)
164
+ if pcm_shared is not None:
165
+ fig.colorbar(
166
+ pcm_shared, ax=self.ax_ov, label=self.cbar_label,
167
+ location="right", shrink=0.9,
168
+ )
169
+
170
+ self.ax_strip[0].set_ylabel("Depth (m)")
171
+ self._placeholder("click an overview cell (or use the arrow keys)")
172
+ if self.title:
173
+ fig.suptitle(self.title, fontsize=10)
174
+ # Set the shared depth limits last: _placeholder's clear() of the (now
175
+ # depth-linked) strip/diag axes autoscales the shared group, so the
176
+ # data-span limit has to be applied after it to stick.
177
+ self._set_overview_depth_limits()
178
+
179
+ def _set_overview_depth_limits(self) -> None:
180
+ """Set the (inverted) overview depth axis to the span of finite data.
181
+
182
+ Takes the union, over the overview fields, of bins holding at least one
183
+ finite cast; the axis then covers only sampled depths (with a small pad)
184
+ rather than the full — often much deeper — combo bin grid. Falls back to
185
+ a plain inversion of the full range when nothing is finite.
186
+ """
187
+ bins = self.data.bin
188
+ has_data = np.zeros(bins.shape, dtype=bool)
189
+ for z in self.data.fields.values():
190
+ has_data |= np.isfinite(z).any(axis=1)
191
+ if has_data.any():
192
+ sampled = bins[has_data]
193
+ d_lo, d_hi = float(sampled.min()), float(sampled.max())
194
+ pad = 0.03 * (d_hi - d_lo) + 1.0
195
+ self.ax_ov[0].set_ylim(d_hi + pad, max(d_lo - pad, 0.0))
196
+ elif not self.ax_ov[0].yaxis_inverted():
197
+ self.ax_ov[0].invert_yaxis()
198
+
199
+ # -------------------------------------------------------------- selection
200
+ def _placeholder(self, msg: str) -> None:
201
+ for ax in [self.ax_spec, *self.ax_strip, *self.ax_diag]:
202
+ ax.clear()
203
+ self.ax_spec.text(0.5, 0.5, msg, transform=self.ax_spec.transAxes,
204
+ ha="center", va="center", fontsize=9)
205
+
206
+ def select(self, i_profile: int, i_depth: int) -> None:
207
+ """Select the cell at (profile, depth) and redraw the drill-down."""
208
+ n_prof = len(self.data.stime_epoch)
209
+ n_bin = len(self.data.bin)
210
+ self.iProfile = int(np.clip(i_profile, 0, max(n_prof - 1, 0)))
211
+ self.iDepth = int(np.clip(i_depth, 0, max(n_bin - 1, 0)))
212
+
213
+ x = float(self.cast_x[self.iProfile])
214
+ y = float(self.data.bin[self.iDepth])
215
+ for xh, yh in zip(self._xhair, self._yhair):
216
+ xh.set_xdata([x, x])
217
+ xh.set_visible(True)
218
+ yh.set_ydata([y, y])
219
+ yh.set_visible(True)
220
+
221
+ # Depth is one shared axis across the overview, strip and diag rows;
222
+ # preserve its current limits (the data-span default or the user's zoom)
223
+ # across the in-place redraws, whose clear()+plot would otherwise
224
+ # autoscale the whole linked group to this one profile.
225
+ depth_ylim = self.ax_ov[0].get_ylim()
226
+ stime = float(self.data.stime_epoch[self.iProfile])
227
+ cell = self.source.cell(stime, y)
228
+ if cell is None:
229
+ self._placeholder(f"no per-profile file for cast {self.iProfile + 1}")
230
+ else:
231
+ # Tell the renderer which overview field was selected; the mixing
232
+ # drill-down keys its spectra off it (K_rho -> shear, K_T -> chi).
233
+ cell.field = self.field_specs[self.iField][0]
234
+ self.spectra_fn(self.ax_spec, cell)
235
+ self.strip_fn(self.ax_strip, cell)
236
+ for ax in self.ax_strip[1:]:
237
+ ax.tick_params(labelleft=False)
238
+ if self.diag_fn is not None and self.ax_diag:
239
+ self.diag_fn(self.ax_diag, cell)
240
+ for ax in self.ax_diag[1:]:
241
+ ax.tick_params(labelleft=False)
242
+ src = cell.profile.path.rsplit("/", 1)[-1]
243
+ self.fig.suptitle(
244
+ f"{self.title}\ncast {self.iProfile + 1}/{n_prof} — {src}",
245
+ fontsize=9,
246
+ )
247
+ self.ax_ov[0].set_ylim(depth_ylim) # restore the shared depth range
248
+ self.fig.canvas.draw_idle()
249
+
250
+ # ----------------------------------------------------------------- events
251
+ def _on_click(self, event) -> None:
252
+ if event.inaxes not in self.ax_ov:
253
+ return
254
+ if event.xdata is None or event.ydata is None:
255
+ return
256
+ # Remember which panel was clicked so the drill-down can adapt to the
257
+ # selected field (mixing: K_rho -> shear spectrum, K_T -> chi spectrum).
258
+ self.iField = self.ax_ov.index(event.inaxes)
259
+ i_profile = int(np.argmin(np.abs(self.cast_x - event.xdata)))
260
+ i_depth = int(np.argmin(np.abs(self.data.bin - event.ydata)))
261
+ self.select(i_profile, i_depth)
262
+
263
+ def _on_key(self, event) -> None:
264
+ moves = {
265
+ "left": (-1, 0), "right": (1, 0),
266
+ "up": (0, -1), "down": (0, 1),
267
+ }
268
+ if event.key not in moves:
269
+ return
270
+ dp, dd = moves[event.key]
271
+ self.select(self.iProfile + dp, self.iDepth + dd)
272
+
273
+ def connect(self) -> None:
274
+ """Wire click + arrow-key handlers, suppressing matplotlib's key nav."""
275
+ canvas = self.fig.canvas
276
+ # Drop matplotlib's built-in key handler so left/right arrows drive the
277
+ # cell selection instead of the toolbar's back/forward navigation.
278
+ mgr = getattr(canvas, "manager", None)
279
+ hid = getattr(mgr, "key_press_handler_id", None)
280
+ if hid is not None:
281
+ canvas.mpl_disconnect(hid)
282
+ canvas.mpl_connect("button_press_event", self._on_click)
283
+ canvas.mpl_connect("key_press_event", self._on_key)
284
+
285
+ # -------------------------------------------------------------- launching
286
+ def default_cell(self) -> tuple[int, int]:
287
+ """A representative (depth, profile): the cast with the most data, at a
288
+ mid depth among its finite bins. Returns (depth-bin index, profile
289
+ index) — the order the caller unpacks. Used for a headless snapshot."""
290
+ primary = next(iter(self.data.fields.values()))
291
+ finite = np.isfinite(primary)
292
+ if not finite.any():
293
+ return 0, 0
294
+ j = int(np.argmax(finite.sum(axis=0)))
295
+ rows = np.where(finite[:, j])[0]
296
+ return int(rows[len(rows) // 2]), j
297
+
298
+ def show(self) -> None:
299
+ import matplotlib.pyplot as plt
300
+
301
+ self.connect()
302
+ self._start_prewarm()
303
+ plt.show()
304
+
305
+ def _start_prewarm(self) -> None:
306
+ """Scan the per-profile directories on a background thread while the
307
+ user reads the overview, so the first cell click renders promptly.
308
+
309
+ The scan is pure file I/O (no matplotlib), and the GUI event loop on the
310
+ main thread releases the GIL, so the thread makes progress during
311
+ ``plt.show()``. Best-effort: a failure just leaves the lazy click path.
312
+ """
313
+ import threading
314
+
315
+ def _work() -> None:
316
+ # Best-effort: a failed scan just leaves the lazy click path intact.
317
+ with contextlib.suppress(Exception):
318
+ self.source.prewarm()
319
+
320
+ threading.Thread(
321
+ target=_work, daemon=True, name="perturb-diag-prewarm"
322
+ ).start()
323
+
324
+ def snapshot(self, out_path: str, *, dpi: int = 110) -> str:
325
+ """Render the default cell and save a static PNG (headless-friendly)."""
326
+ i_depth, i_profile = self.default_cell()
327
+ self.select(i_profile, i_depth)
328
+ self.fig.savefig(out_path, dpi=dpi)
329
+ return out_path
330
+
331
+
332
+ def _safe_lognorm(vmin, vmax):
333
+ """LogNorm for finite positive limits, else None (empty overview)."""
334
+ from matplotlib.colors import LogNorm
335
+
336
+ if vmin is None or vmax is None:
337
+ return None
338
+ if not (np.isfinite(vmin) and np.isfinite(vmax)) or vmin <= 0 or vmax <= 0:
339
+ return None
340
+ if vmin >= vmax:
341
+ vmin = vmax / 10.0
342
+ return LogNorm(vmin=vmin, vmax=vmax)
@@ -0,0 +1,94 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """``perturb-diag mixing`` — interactive mixing-coefficient inspector.
3
+
4
+ Overview: K_rho, K_T and Gamma over cast x depth (from chi_combo, populated by a
5
+ mixing run with CTD). The drill-down is *adaptive* — it follows the physics of
6
+ the clicked cell: K_rho is set by shear (epsilon), so clicking a K_rho cell
7
+ shows that cast's shear spectrum (from the diss file); K_T is set by the
8
+ temperature gradient (chi), so a K_T cell shows its Batchelor spectrum (from the
9
+ chi file); Gamma couples both, so it points at the two. Below the spectra, a
10
+ strip of epsilon / chi / N^2 / dT-dz / K_T / K_rho / Gamma vs depth, and the raw
11
+ instrument-diagnostics row.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+
19
+ from odas_tpw.perturb import resolve
20
+ from odas_tpw.perturb.diag import _common, render
21
+ from odas_tpw.perturb.diag.data import (
22
+ MixingCellSource,
23
+ apply_sections,
24
+ load_overview,
25
+ )
26
+ from odas_tpw.perturb.diag.inspector import DiagInspector
27
+
28
+ # Overview panels: (chi_combo variable, mathtext title). Each has its own units
29
+ # (m^2/s, m^2/s, dimensionless), so the inspector scales them independently.
30
+ _FIELDS: list[tuple[str, str]] = [
31
+ ("K_rho", r"$K_\rho$"),
32
+ ("K_T", r"$K_T$"),
33
+ ("Gamma", r"$\Gamma$"),
34
+ ]
35
+
36
+
37
+ def add_arguments(p: argparse.ArgumentParser) -> None:
38
+ """Register CLI flags for the mixing diagnostics subcommand on *p*."""
39
+ _common.add_common_args(p)
40
+
41
+
42
+ def run(args: argparse.Namespace) -> str:
43
+ """Launch the interactive inspector (or write a snapshot with --out)."""
44
+ args.root = resolve.require_root(args)
45
+
46
+ combo_dir = resolve.resolve_for_args(args, "chi_combo")
47
+ if combo_dir is None:
48
+ raise SystemExit(f"No chi_combo dir under {args.root}")
49
+ combo_path = os.path.join(combo_dir, "combo.nc")
50
+
51
+ chi_dir = resolve.resolve_for_args(args, "chi", optional=True, conflict_ok=True)
52
+ diss_dir = resolve.resolve_for_args(args, "diss", optional=True, conflict_ok=True)
53
+ if chi_dir is None or diss_dir is None:
54
+ raise SystemExit(
55
+ f"perturb-diag mixing needs both per-profile chi_NN and diss_NN dirs "
56
+ f"under {args.root} for the adaptive drill-down"
57
+ )
58
+
59
+ # The coarse overview map uses the chi flag for every field. K_rho is
60
+ # epsilon-derived and would ideally gate on qc_drop_epsilon, but under the
61
+ # default drop_action="nan" it is already NaN wherever epsilon dropped, so a
62
+ # single chi flag over-masks K_rho only under the non-default "flag" mode
63
+ # (the per-cell drill-down below still reads the true per-quantity values).
64
+ data = load_overview(
65
+ combo_path, tuple(n for n, _ in _FIELDS),
66
+ qc_var="qc_drop_chi", apply_qc=args.apply_qc,
67
+ )
68
+ data = apply_sections(data, args.sections, args.select)
69
+
70
+ profiles_dir = _common.resolve_profiles_dir(args)
71
+ source = MixingCellSource(diss_dir, chi_dir, profiles_dir=profiles_dir)
72
+ title = args.title or os.path.basename(os.path.normpath(args.root))
73
+
74
+ inspector = DiagInspector(
75
+ data, source,
76
+ field_specs=_FIELDS,
77
+ spectra_fn=render.draw_mixing_spectra,
78
+ strip_fn=render.draw_mixing_strip,
79
+ n_strip=7,
80
+ diag_fn=render.draw_diag_strip if profiles_dir else None,
81
+ n_diag=render.DIAG_PANEL_COUNT if profiles_dir else 0,
82
+ title=title,
83
+ per_field_clim=True, # K_rho/K_T/Gamma differ in units — scale each alone
84
+ clim=tuple(args.clim) if args.clim else None,
85
+ gap_seconds=args.gap_seconds,
86
+ )
87
+
88
+ if args.out:
89
+ out = str(args.out)
90
+ inspector.snapshot(out)
91
+ print(f"Wrote {out}")
92
+ return out
93
+ inspector.show()
94
+ return ""