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,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)
|