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,543 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Pure renderers for ``perturb-diag`` — each takes an Axes and stored arrays.
3
+
4
+ Kept free of interaction state so the same functions serve the interactive
5
+ inspector and headless snapshot rendering. The overview mesh is drawn once;
6
+ the spectra and diagnostic-strip renderers clear and redraw their axes on every
7
+ cell selection.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ from odas_tpw.perturb.diag.data import Cell, ProfileFile, nearest_window
15
+ from odas_tpw.perturb.plot import layout
16
+
17
+ # Per-probe colors, matching the rsi ProfileViewer palette.
18
+ _PROBE_COLORS = ("C0", "C1", "C4", "C5")
19
+
20
+
21
+ def draw_overview_mesh(ax, cast_x, segments, depth, z, cmap, norm):
22
+ """Pcolor a depth-by-cast field on *ax*, one mesh per cluster (no colorbar).
23
+
24
+ Mirrors :func:`layout.plot_panel` but returns the mesh without attaching a
25
+ per-axis colorbar, so the inspector can share one colorbar across the three
26
+ overview panels (they share a color scale).
27
+ """
28
+ z = layout.ffill_down(z)
29
+ d_edges = layout.depth_edges(depth)
30
+ pcm = None
31
+ for s, e in segments:
32
+ x_seg = cast_x[s:e]
33
+ if len(x_seg) == 1:
34
+ edges = np.array([x_seg[0] - 0.5, x_seg[0] + 0.5])
35
+ else:
36
+ mids = 0.5 * (x_seg[:-1] + x_seg[1:])
37
+ edges = np.concatenate(([x_seg[0] - 0.5], mids, [x_seg[-1] + 0.5]))
38
+ pcm = ax.pcolormesh(
39
+ edges, d_edges, z[:, s:e], cmap=cmap, norm=norm, shading="flat"
40
+ )
41
+ return pcm
42
+
43
+
44
+ def band_extent(pf: ProfileFile, window: int) -> tuple[float, float]:
45
+ """Depth interval [lo, hi] of the dissipation cell centered at *window*.
46
+
47
+ Half-height is ``0.5 * (diss_length / fs_fast) * speed`` — the along-track
48
+ length of the fft window converted to depth — matching the Matlab tool's
49
+ grey band. Falls back to +/-0.5 m when the length metadata is absent.
50
+ """
51
+ d0 = float(pf.depth[window])
52
+ diss_len = pf.attrs.get("diss_length")
53
+ fs = pf.attrs.get("fs_fast")
54
+ spd = pf.speed[window] if window < pf.speed.size else np.nan
55
+ if diss_len and fs and np.isfinite(spd):
56
+ half = 0.5 * (float(diss_len) / float(fs)) * float(spd)
57
+ else:
58
+ half = 0.5
59
+ return d0 - half, d0 + half
60
+
61
+
62
+ def draw_eps_spectra(ax, cell: Cell) -> None:
63
+ """Shear wavenumber spectra with Nasmyth fits and K_max markers."""
64
+ from odas_tpw.scor160.nasmyth import nasmyth
65
+
66
+ ax.clear()
67
+ pf, w = cell.profile, cell.window
68
+ K = pf.K[:, w]
69
+ kgood = np.isfinite(K) & (K > 0)
70
+ if not kgood.any():
71
+ ax.text(0.5, 0.5, "no spectrum", transform=ax.transAxes,
72
+ ha="center", va="center")
73
+ return
74
+
75
+ for p in range(pf.n_probe):
76
+ c = _PROBE_COLORS[p % len(_PROBE_COLORS)]
77
+ shear = pf.spec_shear[p, :, w]
78
+ nas = pf.spec_nasmyth[p, :, w]
79
+ eps = pf.epsilon[p, w]
80
+ kmax = pf.K_max[p, w]
81
+ sgood = kgood & np.isfinite(shear) & (shear > 0)
82
+ if sgood.any():
83
+ ax.loglog(K[sgood], shear[sgood], "-", color=c, linewidth=1.5,
84
+ label=rf"$\epsilon_{p + 1}$={eps:.2g}")
85
+ ngood = kgood & np.isfinite(nas) & (nas > 0)
86
+ if ngood.any():
87
+ ax.loglog(K[ngood], nas[ngood], "--", color=c, linewidth=0.8,
88
+ alpha=0.8)
89
+ if np.isfinite(kmax) and sgood.any():
90
+ ki = int(np.argmin(np.abs(K - kmax)))
91
+ ax.loglog(kmax, shear[ki], marker="v", color=c, markersize=8,
92
+ zorder=5)
93
+
94
+ # Faint reference Nasmyth spectra for orientation.
95
+ nu = pf.nu[w]
96
+ if np.isfinite(nu):
97
+ for exp in (-10, -9, -8, -7, -6, -5):
98
+ ax.loglog(K[kgood], nasmyth(10.0**exp, nu, K[kgood]),
99
+ color="0.85", linewidth=0.3, zorder=0)
100
+
101
+ ax.set_xlabel("k (cpm)")
102
+ ax.set_ylabel(r"$\Phi$(k) (s$^{-2}$ cpm$^{-1}$)")
103
+ ax.grid(True, which="both", alpha=0.3)
104
+ ax.legend(fontsize=6, loc="lower left")
105
+
106
+ em = pf.epsilon_mean[w]
107
+ sig = pf.epsilon_ln_sigma[w]
108
+ lo = f"{np.log10(em):.2f}" if np.isfinite(em) and em > 0 else "?"
109
+ pm = f"{sig / np.log(10):.2f}" if np.isfinite(sig) else "?"
110
+ meth = "/".join(
111
+ "ISR" if pf.method[p, w] == 1 else "var" for p in range(pf.n_probe)
112
+ )
113
+ ax.set_title(
114
+ rf"depth {pf.depth[w]:.1f} m $\log_{{10}}\langle\epsilon\rangle$="
115
+ rf"{lo}$\pm${pm} method [{meth}]",
116
+ fontsize=8,
117
+ )
118
+
119
+
120
+ # Diagnostic strip: (label, per-probe 2-D array or None, per-window 1-D array,
121
+ # log-x flag). Rendered left-to-right vs depth.
122
+ def draw_diss_strip(axes, cell: Cell) -> tuple[float, float]:
123
+ """Per-profile dissipation diagnostics vs depth, with a cell depth band.
124
+
125
+ Returns the (min, max) finite depth so the caller can set a shared,
126
+ inverted y range. Panels: epsilon (per probe + mean), FM, speed, nu, T.
127
+ """
128
+ pf, w = cell.profile, cell.window
129
+ depth = pf.depth
130
+ lo, hi = band_extent(pf, w)
131
+
132
+ # Panel 0: per-probe epsilon (points) + mean (line), log-x.
133
+ ax = axes[0]
134
+ ax.clear()
135
+ for p in range(pf.n_probe):
136
+ c = _PROBE_COLORS[p % len(_PROBE_COLORS)]
137
+ ax.plot(pf.epsilon[p], depth, ".", color=c, markersize=3,
138
+ label=rf"$\epsilon_{p + 1}$")
139
+ ax.plot(pf.epsilon_mean, depth, "-", color="k", linewidth=1.0,
140
+ label=r"$\langle\epsilon\rangle$")
141
+ ax.set_xscale("log")
142
+ ax.set_xlabel(r"$\epsilon$ (W kg$^{-1}$)")
143
+ ax.set_ylabel("Depth (m)")
144
+ ax.legend(fontsize=6, loc="best")
145
+
146
+ # Remaining scalar panels.
147
+ scalars = [
148
+ ("FM", pf.FM, False), # (probe, window)
149
+ ("speed (m/s)", pf.speed, False),
150
+ (r"$\nu$ (m$^2$/s)", pf.nu, False),
151
+ ("T (°C)", pf.T_mean, False),
152
+ ]
153
+ for ax, (xlabel, arr, logx) in zip(axes[1:], scalars):
154
+ ax.clear()
155
+ if arr.ndim == 2:
156
+ for p in range(pf.n_probe):
157
+ c = _PROBE_COLORS[p % len(_PROBE_COLORS)]
158
+ ax.plot(arr[p], depth, "-", color=c, linewidth=0.8)
159
+ else:
160
+ ax.plot(arr, depth, "-", color="C2", linewidth=0.8)
161
+ if logx:
162
+ ax.set_xscale("log")
163
+ ax.set_xlabel(xlabel)
164
+
165
+ # Shared grey depth band + grid on every panel.
166
+ for ax in axes:
167
+ ax.axhspan(lo, hi, color="0.5", alpha=0.25, zorder=0)
168
+ ax.grid(True, alpha=0.3)
169
+
170
+ finite = depth[np.isfinite(depth)]
171
+ if finite.size:
172
+ return float(finite.min()), float(finite.max())
173
+ return 0.0, 1.0
174
+
175
+
176
+ # Raw instrument-diagnostics panels (vs depth): (xlabel, [(channel, color,
177
+ # legend label), ...]). Fast channels overlay the HP+despiked signal on a
178
+ # faint raw trace; the slow inclinometer is shown de-meaned. Grouped to match
179
+ # the epsilon path's sensors: platform attitude, vibration, shear, temp gradient.
180
+ _DIAG_PANELS: list[tuple[str, list[tuple[str, str, str]]]] = [
181
+ ("Incl (° dev.)", [("Incl_X", "C0", "X"), ("Incl_Y", "C1", "Y")]),
182
+ ("Accel (counts)", [("Ax", "C0", "Ax"), ("Ay", "C1", "Ay")]),
183
+ (r"Shear (s$^{-1}$)", [("sh1", "C0", "sh1"), ("sh2", "C1", "sh2")]),
184
+ ("Temp grad", [("T1_dT1", "C0", "dT1"), ("T2_dT2", "C1", "dT2")]),
185
+ ]
186
+
187
+ DIAG_PANEL_COUNT = len(_DIAG_PANELS)
188
+
189
+
190
+ def _stride(y: np.ndarray, depth: np.ndarray, maxn: int = 4000):
191
+ """Uniformly stride a series to ~*maxn* points (fine for smooth signals)."""
192
+ n = y.size
193
+ if n <= maxn:
194
+ return y, depth
195
+ step = n // maxn + 1
196
+ return y[::step], depth[::step]
197
+
198
+
199
+ def _envelope(y: np.ndarray, depth: np.ndarray, maxn: int = 4000):
200
+ """Decimate to ~*maxn* points while preserving local min/max (keeps spikes).
201
+
202
+ For each block of consecutive samples keep the block's minimum and maximum
203
+ (in original order) so despiking-relevant extremes survive the reduction — a
204
+ plain stride would drop the narrow spikes that the raw trace is meant to
205
+ reveal.
206
+ """
207
+ n = y.size
208
+ if n <= maxn:
209
+ return y, depth
210
+ step = int(np.ceil(n / (maxn / 2)))
211
+ m = (n // step) * step
212
+ if m < step:
213
+ return y, depth
214
+ yb = y[:m].reshape(-1, step)
215
+ finite = np.isfinite(yb)
216
+ has = finite.any(axis=1)
217
+ rows = np.nonzero(has)[0]
218
+ if rows.size == 0:
219
+ return _stride(y, depth, maxn)
220
+ safe = np.where(finite, yb, np.nan)
221
+ base = rows * step
222
+ amin = base + np.nanargmin(safe[rows], axis=1)
223
+ amax = base + np.nanargmax(safe[rows], axis=1)
224
+ idx = np.unique(np.concatenate([amin, amax, np.arange(m, n)]))
225
+ return y[idx], depth[idx]
226
+
227
+
228
+ def draw_diag_strip(axes, cell: Cell) -> None:
229
+ """Raw inclinometer/accel/shear/temp-gradient vs depth for the cell's cast.
230
+
231
+ Fast channels overlay the high-pass-filtered signal (faint grey, min/max
232
+ decimated so spikes survive) under the HP+despiked signal — the epsilon
233
+ path's cleanup — in colour; both are zero-mean and in-band, so the panel
234
+ scales to the microstructure signal (not a channel's DC offset / temperature
235
+ ramp) and the grey-to-colour gap is exactly what despike removed. The slow
236
+ inclinometer is de-meaned so its two components share a scale (median tilt in
237
+ the legend). Every trace is clipped to the profile's dissipation depth span,
238
+ so the row lines up with the strip's shared inverted y-axis and drops
239
+ surface/turnaround transients. The grey band marks the selected window.
240
+ Redraws all panels in place on each cell selection.
241
+ """
242
+ rp = cell.raw
243
+ pf, w = cell.profile, cell.window
244
+ lo, hi = band_extent(pf, w)
245
+
246
+ fin = pf.depth[np.isfinite(pf.depth)]
247
+ if fin.size:
248
+ d0, d1 = float(fin.min()), float(fin.max())
249
+ margin = 0.05 * (d1 - d0) + 1.0
250
+ clip_lo, clip_hi = d0 - margin, d1 + margin
251
+ else:
252
+ clip_lo, clip_hi = -np.inf, np.inf
253
+
254
+ for ax, (xlabel, traces) in zip(axes, _DIAG_PANELS):
255
+ ax.clear()
256
+ drew = False
257
+ for ch, color, label in traces:
258
+ if rp is None or ch not in rp.raw:
259
+ continue
260
+ fast = rp.is_fast[ch]
261
+ depth = rp.depth_fast if fast else rp.depth_slow
262
+ sel = np.isfinite(depth) & (depth >= clip_lo) & (depth <= clip_hi)
263
+ if not sel.any():
264
+ continue
265
+ depth_s = depth[sel]
266
+ if fast and ch in rp.proc: # HP (faint) under HP+despike (colored)
267
+ yy, dd = _envelope(rp.hp[ch][sel], depth_s)
268
+ ax.plot(yy, dd, "-", color="0.75", lw=0.3, alpha=0.7, zorder=1)
269
+ py, pdd = _stride(rp.proc[ch][sel], depth_s)
270
+ ax.plot(py, pdd, "-", color=color, lw=0.7, label=label, zorder=2)
271
+ else: # slow inclinometer (or a fast channel too short to filter)
272
+ raw_s = rp.raw[ch][sel]
273
+ med = np.nanmedian(raw_s)
274
+ raw_dm = raw_s - (med if np.isfinite(med) else 0.0)
275
+ lab = f"{label} (μ={med:.1f})" if np.isfinite(med) else label
276
+ yy, dd = _stride(raw_dm, depth_s)
277
+ ax.plot(yy, dd, "-", color=color, lw=0.8, label=lab, zorder=2)
278
+ drew = True
279
+ if drew:
280
+ ax.legend(fontsize=6, loc="best")
281
+ else:
282
+ msg = "no raw data" if rp is not None else "no profiles file"
283
+ ax.text(0.5, 0.5, msg, transform=ax.transAxes, ha="center",
284
+ va="center", fontsize=8)
285
+ ax.set_xlabel(xlabel)
286
+ ax.axhspan(lo, hi, color="0.5", alpha=0.25, zorder=0)
287
+ ax.grid(True, alpha=0.3)
288
+
289
+
290
+ def _geomean_over_probes(x: np.ndarray) -> np.ndarray:
291
+ """Geometric mean across the probe axis (axis 0), ignoring non-positive/NaN."""
292
+ with np.errstate(invalid="ignore", divide="ignore"):
293
+ pos = np.where(x > 0, x, np.nan)
294
+ return np.asarray(np.exp(np.nanmean(np.log(pos), axis=0)))
295
+
296
+
297
+ def _fp07_h2(K: np.ndarray, speed: float, attrs: dict) -> np.ndarray:
298
+ """FP07 response |H|^2 on the wavenumber grid, or all-ones when unavailable.
299
+
300
+ Reconstructs the double-sided response the chi fit divides out: frequency
301
+ ``f = K*speed`` (Hz), the speed-dependent ``tau`` paired with the stored
302
+ ``fp07_model`` (single_pole -> Lueck, double_pole -> Goto; see
303
+ :func:`odas_tpw.chi.fp07.default_tau_model`), then the single- or double-pole
304
+ ``|H|^2``. ``spec_batch`` is stored *without* this rolloff, so the model
305
+ that matches the raw observed spectrum is ``spec_batch*|H|^2 + spec_noise``.
306
+ """
307
+ from odas_tpw.chi.fp07 import (
308
+ default_tau_model,
309
+ fp07_double_pole,
310
+ fp07_tau,
311
+ fp07_transfer,
312
+ )
313
+
314
+ model = str(attrs.get("fp07_model", "")) if attrs else ""
315
+ if not model or not np.isfinite(speed) or speed <= 0:
316
+ return np.ones_like(K)
317
+ tau0 = float(fp07_tau(float(speed), default_tau_model(model)))
318
+ f = np.asarray(K, dtype=float) * float(speed)
319
+ tf = fp07_double_pole if model == "double_pole" else fp07_transfer
320
+ return np.asarray(tf(f, tau0), dtype=float)
321
+
322
+
323
+ def draw_chi_spectra(ax, cell: Cell) -> None:
324
+ """Temperature-gradient wavenumber spectra with the fitted model + noise floor.
325
+
326
+ The chi analog of :func:`draw_eps_spectra`: per probe the observed gradient
327
+ spectrum (solid) and, dashed, the fitted model that it is compared against —
328
+ the Batchelor/Kraichnan spectrum rolled off by the FP07 response and lifted
329
+ by the electronics-noise floor (``spec_batch*|H|^2 + spec_noise``), the same
330
+ combination the fom integrates. The noise floor alone is dotted, and the
331
+ upper fit wavenumber ``K_max_T`` marked. (Plotting the stored ``spec_batch``
332
+ directly would omit the FP07 rolloff and the floor, so the "model" would sit
333
+ well off the data — the fit only looks wrong without them.)
334
+ """
335
+ ax.clear()
336
+ cf, w = cell.profile, cell.window
337
+ K = cf.K[:, w]
338
+ kgood = np.isfinite(K) & (K > 0)
339
+ if not kgood.any():
340
+ ax.text(0.5, 0.5, "no spectrum", transform=ax.transAxes,
341
+ ha="center", va="center")
342
+ return
343
+
344
+ h2 = _fp07_h2(K, float(cf.speed[w]), cf.attrs)
345
+ for p in range(cf.n_probe):
346
+ c = _PROBE_COLORS[p % len(_PROBE_COLORS)]
347
+ obs = cf.spec_gradT[p, :, w]
348
+ noise = cf.spec_noise[p, :, w]
349
+ model = cf.spec_batch[p, :, w] * h2 + noise
350
+ og = kgood & np.isfinite(obs) & (obs > 0)
351
+ if og.any():
352
+ ax.loglog(K[og], obs[og], "-", color=c, linewidth=1.5,
353
+ label=rf"$\chi_{p + 1}$={cf.chi[p, w]:.2g}")
354
+ mg = kgood & np.isfinite(model) & (model > 0)
355
+ if mg.any():
356
+ ax.loglog(K[mg], model[mg], "--", color=c, linewidth=1.1, alpha=0.9)
357
+ ng = kgood & np.isfinite(noise) & (noise > 0)
358
+ if ng.any():
359
+ ax.loglog(K[ng], noise[ng], ":", color=c, linewidth=0.6, alpha=0.5)
360
+ kmax = cf.K_max_T[p, w]
361
+ if np.isfinite(kmax) and og.any():
362
+ ki = int(np.argmin(np.abs(K - kmax)))
363
+ ax.loglog(kmax, obs[ki], marker="v", color=c, markersize=8, zorder=5)
364
+
365
+ # Focus the y-range on the observed spectrum within the fit band; the model's
366
+ # tail beyond K_max can run orders of magnitude above the rolled-off data.
367
+ kmax_all = cf.K_max_T[:, w]
368
+ k_hi = float(np.nanmax(kmax_all)) if np.isfinite(kmax_all).any() else np.inf
369
+ infit = kgood & np.less_equal(K, k_hi)
370
+ vals = cf.spec_gradT[:, infit, w] if infit.any() else cf.spec_gradT[:, kgood, w]
371
+ vals = vals[np.isfinite(vals) & (vals > 0)]
372
+ if vals.size:
373
+ ax.set_ylim(float(vals.min()) * 0.3, float(vals.max()) * 3.0)
374
+
375
+ ax.set_xlabel("k (cpm)")
376
+ ax.set_ylabel(r"$\Psi$(k) (K$^2$ m$^{-1}$)")
377
+ ax.grid(True, which="both", alpha=0.3)
378
+ ax.legend(fontsize=6, loc="lower left")
379
+
380
+ cm = float(_geomean_over_probes(cf.chi[:, w]))
381
+ lo = f"{np.log10(cm):.2f}" if np.isfinite(cm) and cm > 0 else "?"
382
+ fom = "/".join(f"{cf.fom[p, w]:.2f}" for p in range(cf.n_probe))
383
+ spectrum = str(cf.attrs.get("spectrum_model", "")).capitalize()
384
+ method = str(cf.attrs.get("fit_method", ""))
385
+ tag = " · ".join(t for t in (spectrum, f"{method} fit" if method else "") if t)
386
+ title = (
387
+ rf"depth {cf.depth[w]:.1f} m $\log_{{10}}\langle\chi\rangle$={lo}"
388
+ rf" fom [{fom}]"
389
+ )
390
+ if tag:
391
+ title += f" {tag}"
392
+ ax.set_title(title, fontsize=8)
393
+
394
+
395
+ def draw_chi_strip(axes, cell: Cell) -> tuple[float, float]:
396
+ """Per-profile chi diagnostics vs depth: chi (per probe + geomean), fom,
397
+ speed, nu, T. Mirrors :func:`draw_diss_strip` (which see for the shared
398
+ grey band / inverted-depth contract)."""
399
+ cf, w = cell.profile, cell.window
400
+ depth = cf.depth
401
+ lo, hi = band_extent(cf, w)
402
+
403
+ ax = axes[0]
404
+ ax.clear()
405
+ for p in range(cf.n_probe):
406
+ c = _PROBE_COLORS[p % len(_PROBE_COLORS)]
407
+ ax.plot(cf.chi[p], depth, ".", color=c, markersize=3,
408
+ label=rf"$\chi_{p + 1}$")
409
+ ax.plot(_geomean_over_probes(cf.chi), depth, "-", color="k", linewidth=1.0,
410
+ label=r"$\langle\chi\rangle$")
411
+ ax.set_xscale("log")
412
+ ax.set_xlabel(r"$\chi$ (K$^2$ s$^{-1}$)")
413
+ ax.set_ylabel("Depth (m)")
414
+ ax.legend(fontsize=6, loc="best")
415
+
416
+ scalars = [
417
+ ("fom", cf.fom, False), # (probe, window)
418
+ ("speed (m/s)", cf.speed, False),
419
+ (r"$\nu$ (m$^2$/s)", cf.nu, False),
420
+ ("T (°C)", cf.T_mean, False),
421
+ ]
422
+ for ax, (xlabel, a, logx) in zip(axes[1:], scalars):
423
+ ax.clear()
424
+ if a.ndim == 2:
425
+ for p in range(cf.n_probe):
426
+ c = _PROBE_COLORS[p % len(_PROBE_COLORS)]
427
+ ax.plot(a[p], depth, "-", color=c, linewidth=0.8)
428
+ else:
429
+ ax.plot(a, depth, "-", color="C2", linewidth=0.8)
430
+ if logx:
431
+ ax.set_xscale("log")
432
+ ax.set_xlabel(xlabel)
433
+
434
+ for ax in axes:
435
+ ax.axhspan(lo, hi, color="0.5", alpha=0.25, zorder=0)
436
+ ax.grid(True, alpha=0.3)
437
+
438
+ finite = depth[np.isfinite(depth)]
439
+ if finite.size:
440
+ return float(finite.min()), float(finite.max())
441
+ return 0.0, 1.0
442
+
443
+
444
+ def _spectra_msg(ax, msg: str) -> None:
445
+ ax.clear()
446
+ ax.text(0.5, 0.5, msg, transform=ax.transAxes, ha="center", va="center",
447
+ fontsize=9)
448
+ ax.set_xticks([])
449
+ ax.set_yticks([])
450
+
451
+
452
+ def draw_mixing_spectra(ax, cell: Cell) -> None:
453
+ """Adaptive drill-down spectrum, keyed off the clicked overview field.
454
+
455
+ Per the mixing decomposition, K_rho comes from shear (epsilon) and K_T from
456
+ the temperature gradient (chi), so clicking a K_rho cell shows that cast's
457
+ shear spectrum (from the diss file), a K_T cell its temperature-gradient
458
+ Batchelor spectrum (from the chi file), and Gamma — which couples both — a
459
+ note pointing at the two. The chi profile is primary, so the window's depth
460
+ picks the matching diss window.
461
+ """
462
+ field = (cell.field or "").lower()
463
+ profs = cell.profiles or {}
464
+ chi = cell.profile # primary
465
+ depth = (
466
+ float(chi.depth[cell.window])
467
+ if cell.window < chi.depth.size else float("nan")
468
+ )
469
+
470
+ if field.startswith("k_rho"):
471
+ eps = profs.get("eps")
472
+ if eps is None:
473
+ _spectra_msg(ax, "no epsilon file matched this cast")
474
+ return
475
+ w = nearest_window(eps.depth, depth)
476
+ if w is None:
477
+ _spectra_msg(ax, "no finite epsilon window for this cast")
478
+ return
479
+ draw_eps_spectra(ax, Cell(profile=eps, window=w))
480
+ elif field.startswith("k_t"):
481
+ draw_chi_spectra(ax, Cell(profile=chi, window=cell.window))
482
+ else: # Gamma (or anything else): no single spectrum — it couples eps & chi
483
+ _spectra_msg(
484
+ ax,
485
+ r"$\Gamma = N^2\,\chi\,/\,(2\,\epsilon\,(dT/dz)^2)$"
486
+ "\n\nclick K_rho for the shear spectrum,\n"
487
+ "K_T for the temperature-gradient spectrum",
488
+ )
489
+
490
+
491
+ def _strip_panel(ax, x, depth, xlabel, lo, hi, *, logx=False, color="C2"):
492
+ """One mixing-strip panel: *x* vs *depth*, or an 'n/a' note when absent."""
493
+ ax.clear()
494
+ xa = None if x is None else np.asarray(x, dtype=float)
495
+ if xa is None or not np.isfinite(xa).any():
496
+ ax.text(0.5, 0.5, "n/a", transform=ax.transAxes, ha="center",
497
+ va="center", fontsize=8, color="0.5")
498
+ else:
499
+ ax.plot(xa, depth, "-", color=color, linewidth=0.9)
500
+ if logx:
501
+ ax.set_xscale("log")
502
+ ax.set_xlabel(xlabel)
503
+ ax.axhspan(lo, hi, color="0.5", alpha=0.25, zorder=0)
504
+ ax.grid(True, alpha=0.3)
505
+
506
+
507
+ def draw_mixing_strip(axes, cell: Cell) -> tuple[float, float]:
508
+ """Mixing diagnostics vs depth: epsilon, chi, N^2, dT/dz, K_T, K_rho, Gamma.
509
+
510
+ Epsilon comes from the diss file (per-window mean, on its own grid); the rest
511
+ come from the chi file, including the mixing quantities a mixing run appended
512
+ (N2/dTdz/K_T/K_rho/Gamma) — those panels read 'n/a' when the run had no CTD.
513
+ Mirrors :func:`draw_diss_strip`'s shared grey-band / inverted-depth contract.
514
+ """
515
+ chi = cell.profile
516
+ eps = (cell.profiles or {}).get("eps")
517
+ d_chi = chi.depth
518
+ lo, hi = band_extent(chi, cell.window)
519
+
520
+ axes[0].set_ylabel("Depth (m)")
521
+ e_x = eps.epsilon_mean if eps is not None else None
522
+ d_eps = eps.depth if eps is not None else d_chi
523
+ _strip_panel(axes[0], e_x, d_eps, r"$\epsilon$ (W kg$^{-1}$)", lo, hi,
524
+ logx=True, color="C0")
525
+ _strip_panel(axes[1], _geomean_over_probes(chi.chi), d_chi,
526
+ r"$\chi$ (K$^2$ s$^{-1}$)", lo, hi, logx=True, color="C1")
527
+ _strip_panel(axes[2], chi.N2, d_chi, r"$N^2$ (s$^{-2}$)", lo, hi,
528
+ logx=True, color="C2")
529
+ _strip_panel(axes[3], chi.dTdz, d_chi, r"dT/dz (K m$^{-1}$)", lo, hi,
530
+ color="C3")
531
+ _strip_panel(axes[4], chi.K_T, d_chi, r"$K_T$ (m$^2$ s$^{-1}$)", lo, hi,
532
+ logx=True, color="C4")
533
+ _strip_panel(axes[5], chi.K_rho, d_chi, r"$K_\rho$ (m$^2$ s$^{-1}$)", lo, hi,
534
+ logx=True, color="C5")
535
+ _strip_panel(axes[6], chi.Gamma, d_chi, r"$\Gamma$", lo, hi, color="C6")
536
+
537
+ spans = [d_chi[np.isfinite(d_chi)]]
538
+ if eps is not None:
539
+ spans.append(np.asarray(d_eps)[np.isfinite(d_eps)])
540
+ alld = np.concatenate(spans)
541
+ if alld.size:
542
+ return float(alld.min()), float(alld.max())
543
+ return 0.0, 1.0
@@ -0,0 +1,40 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """File discovery for .p files.
3
+
4
+ Reference: Code/find_P_filenames.m
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+
10
+ def find_p_files(root: str | Path, pattern: str = "**/*.p") -> list[Path]:
11
+ """Find .p files under *root* matching *pattern*.
12
+
13
+ Filters out ``_original.p`` files and dotfiles (hidden files).
14
+ Returns a sorted list of paths.
15
+
16
+ Parameters
17
+ ----------
18
+ root : str or Path
19
+ Directory to search.
20
+ pattern : str
21
+ Glob pattern (default ``"**/*.p"``).
22
+
23
+ Returns
24
+ -------
25
+ list of Path
26
+ Sorted list of matching .p file paths.
27
+ """
28
+ root = Path(root)
29
+ results = []
30
+ for p in root.glob(pattern):
31
+ if not p.is_file():
32
+ continue
33
+ if p.suffix.lower() != ".p":
34
+ continue
35
+ if p.name.startswith("."):
36
+ continue
37
+ if p.stem.endswith("_original"):
38
+ continue
39
+ results.append(p)
40
+ return sorted(results)