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,1097 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Mixing-efficiency scaling figures (Lewin et al. 2025, Fig. 5).
3
+
4
+ ``perturb-plot gamma-scaling`` sweeps a run's **per-window** products
5
+ (``chi_NN`` + ``profiles_NN``, plus ``diss_NN`` for QC flags / legacy
6
+ epsilon re-pairing when the chi product lacks ``epsilon_paired``) and
7
+ renders the flux
8
+ coefficient Gamma against the three dimensionless mixing parameters of
9
+ Lewin et al. (2025, https://doi.org/10.1175/JPO-D-25-0012.1):
10
+
11
+ - ``R_OT = L_O / L_T`` — Ozmidov over Thorpe scale (turbulence age proxy),
12
+ - ``Re_b = epsilon / (nu * N2_patch)`` — buoyancy Reynolds number,
13
+ - ``Ri_g = N2 / S2`` — gradient Richardson number (needs ``--adcp``).
14
+
15
+ Each panel: scatter of per-window values (colored by ``--sections``
16
+ windows when given — the analog of the paper's W1/W2), medians in
17
+ equispaced log-x bins with 5/95% whiskers, marginal pdfs, and the
18
+ literature slope guides (-1 and -4/3 on R_OT; -1/2 on Re_b; +1 on Ri_g).
19
+ A companion figure compares the two Thorpe-sort routes (JAC sigma0 vs
20
+ FP07 temperature) so the density route's ``L_T`` floor can be judged
21
+ against data rather than assumed.
22
+
23
+ Method notes (deliberate deviations from the paper are called out):
24
+
25
+ - **Gamma is recomputed from unmasked components** (``chiMean``, paired
26
+ ``epsilonMean``, ``N2``, ``dTdz``) rather than read from the stored
27
+ ``Gamma``: the pipeline's mixing product masks ``Gamma > 5`` and
28
+ ``|dTdz| < 1e-4`` (sane for maps, but exactly the tails this figure
29
+ exists to show — the paper's axis runs to 10 and its weak-gradient
30
+ windows are *kept*, filtered instead by the Cox number). QC here is
31
+ the paper's: ``Re_b > 20``, ``C_x > 50``, upper ``--min-depth`` cut,
32
+ plus the pipeline's ``qc_drop_*`` hotel flags.
33
+ - **Thorpe scales** come from the slow (64 Hz) grid — both routes:
34
+ ``sigma0`` (JAC CT; trusted salinity, so an upgrade on the paper's
35
+ temperature-only sort) and the FP07 ``T1`` (the paper's proxy) — over
36
+ a ``--sort-window`` (default 4 s, the paper's segment span) centered
37
+ on each chi window. ``N2_patch`` is the Smyth/Kaminski
38
+ overturn-weighted form (see :mod:`odas_tpw.processing.thorpe`); when
39
+ ``L_T`` is below the route's floor the window falls back to the
40
+ chi-window background ``N2`` (the paper does the same below 0.05 m).
41
+ - **Our N2 is full TEOS-10** (temperature + JAC salinity); the paper's
42
+ is temperature-only (their Eq. 4). Better physics, but medians can
43
+ differ systematically where salinity stratifies or compensates.
44
+ - **Ri_g is a coarse-grained analog, not a clone**: shipboard-ADCP
45
+ S2 over >= 2-m bins and ~120-s ensembles vs their 3-m pole ADCP at
46
+ 20-s cadence. The numerator N2 is recomputed over ``--ri-n2-span``
47
+ (default 4 m ~ 2 ADCP bins) so both Ri_g operands live at a matched
48
+ vertical scale. Multiple ``--adcp`` sonars are **never blended** —
49
+ each gets its own panel (16-m os75 shear is not the same quantity as
50
+ 2-m wh300 shear).
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import argparse
56
+ import glob
57
+ import os
58
+ import sys
59
+ from dataclasses import dataclass, field
60
+ from typing import TYPE_CHECKING, Any
61
+
62
+ import numpy as np
63
+ import xarray as xr
64
+
65
+ from odas_tpw.perturb import resolve
66
+ from odas_tpw.perturb.hotel import _dt64_to_epoch_s
67
+ from odas_tpw.perturb.plot import xaxis
68
+ from odas_tpw.perturb.plot.sections import (
69
+ add_output_arguments,
70
+ can_display,
71
+ close_new_figs_on_error,
72
+ closing_figs,
73
+ fig_dpi,
74
+ grouped,
75
+ load_sections,
76
+ save_or_show,
77
+ select_sections,
78
+ )
79
+
80
+ if TYPE_CHECKING:
81
+ from collections.abc import Iterator
82
+
83
+ from matplotlib.axes import Axes
84
+ from matplotlib.figure import Figure
85
+
86
+ # Paper QC defaults (Lewin et al. 2025 section 2): discard Re_b <= 20 and
87
+ # Cox number <= 50; drop the ship-wake-contaminated upper water column.
88
+ DEFAULT_MIN_REB = 20.0
89
+ DEFAULT_MIN_COX = 50.0
90
+ DEFAULT_MIN_DEPTH = 10.0 # [dbar]
91
+
92
+ # diss->chi window pairing tolerance [s]. The pipeline writes both products
93
+ # on the same window grid (sub-ms offsets); 0.5 s tolerates a half-step
94
+ # offset without ever pairing across neighboring 1-s windows.
95
+ DEFAULT_MAX_DT = 0.5
96
+
97
+ # Vertical span [m] for the Ri_g numerator's N2 (~2 wh300 bins), so N2 and
98
+ # S2 live at a matched scale; 0 falls back to the chi-window N2.
99
+ DEFAULT_RI_N2_SPAN = 4.0
100
+
101
+ _GUIDES: dict[str, tuple[tuple[float, str], ...]] = {
102
+ "R_OT": ((-1.0, "-1"), (-4.0 / 3.0, "-4/3")),
103
+ "Re_b": ((-0.5, "-1/2"),),
104
+ "Ri_g": ((1.0, "1"),),
105
+ }
106
+
107
+
108
+ @dataclass
109
+ class _Table:
110
+ """Window-level columns concatenated over every profile."""
111
+
112
+ cols: dict[str, np.ndarray] = field(default_factory=dict)
113
+ s2: dict[str, np.ndarray] = field(default_factory=dict) # per sonar
114
+ rig: dict[str, np.ndarray] = field(default_factory=dict) # per sonar
115
+ n_profiles: int = 0
116
+
117
+ def __getitem__(self, key: str) -> np.ndarray:
118
+ return self.cols[key]
119
+
120
+ @property
121
+ def n(self) -> int:
122
+ return self.cols["epoch"].size if self.cols else 0
123
+
124
+
125
+ def add_arguments(p: argparse.ArgumentParser) -> None:
126
+ """Register the ``gamma-scaling`` flags on *p*."""
127
+ p.add_argument(
128
+ "--root",
129
+ default=None,
130
+ help="perturb output root (contains chi_NN/diss_NN/"
131
+ "profiles_NN). Required unless --config is given.",
132
+ )
133
+ resolve.add_resolve_args(p)
134
+ p.add_argument(
135
+ "--sections",
136
+ default=None,
137
+ help="sections YAML; windows are colored by which section's "
138
+ "start/stop window they fall in (only name/start/stop "
139
+ "are used — the x-axis entries are ignored here)",
140
+ )
141
+ p.add_argument(
142
+ "--select",
143
+ action="append",
144
+ default=None,
145
+ metavar="NAME",
146
+ help="use only the named section(s) for coloring (repeatable or comma-separated)",
147
+ )
148
+ p.add_argument(
149
+ "--out-dir",
150
+ default=None,
151
+ help="write PNGs here instead of showing on screen "
152
+ "(default: display; headless falls back to --root)",
153
+ )
154
+ p.add_argument(
155
+ "--adcp",
156
+ action="append",
157
+ default=None,
158
+ metavar="FILE",
159
+ help="CODAS gridded ADCP NetCDF (e.g. .../wh300/contour/"
160
+ "wh300.nc) for the Ri_g panel; repeatable — each "
161
+ "sonar gets its OWN panel (never blended)",
162
+ )
163
+ p.add_argument(
164
+ "--min-pg",
165
+ type=float,
166
+ default=None,
167
+ help="optional ADCP percent-good floor (default: none — "
168
+ "the CODAS contour product is already edited)",
169
+ )
170
+ p.add_argument(
171
+ "--time-tolerance",
172
+ type=float,
173
+ default=300.0,
174
+ metavar="SEC",
175
+ help="ADCP ensembles within this many seconds of a window "
176
+ "are averaged for S2 (default 300)",
177
+ )
178
+ p.add_argument(
179
+ "--sort-window",
180
+ type=float,
181
+ default=4.0,
182
+ metavar="SEC",
183
+ help="Thorpe sort-window span centered on each chi window "
184
+ "(default 4 s — the paper's segment span; wider "
185
+ "resolves larger overturns)",
186
+ )
187
+ p.add_argument(
188
+ "--route",
189
+ choices=("density", "temperature"),
190
+ default="density",
191
+ help="which Thorpe route feeds R_OT/Re_b: density "
192
+ "(JAC sigma0; default) or temperature (FP07 T1, "
193
+ "paper-style)",
194
+ )
195
+ p.add_argument(
196
+ "--lt-floor-density",
197
+ type=float,
198
+ default=None,
199
+ metavar="M",
200
+ help="L_T floor for the sigma0 route (default 0.10 m — "
201
+ "provisional; judge it from the route-comparison "
202
+ "figure)",
203
+ )
204
+ p.add_argument(
205
+ "--lt-floor-temperature",
206
+ type=float,
207
+ default=None,
208
+ metavar="M",
209
+ help="L_T floor for the T1 route (default 0.05 m, per the paper)",
210
+ )
211
+ p.add_argument(
212
+ "--min-reb",
213
+ type=float,
214
+ default=DEFAULT_MIN_REB,
215
+ help="discard windows with Re_b below this (default 20, per the paper; 0 disables)",
216
+ )
217
+ p.add_argument(
218
+ "--min-cox",
219
+ type=float,
220
+ default=DEFAULT_MIN_COX,
221
+ help="discard windows with Cox number below this (default 50, per the paper; 0 disables)",
222
+ )
223
+ p.add_argument(
224
+ "--min-depth",
225
+ type=float,
226
+ default=DEFAULT_MIN_DEPTH,
227
+ metavar="DBAR",
228
+ help="discard windows shallower than this (default 10 — ship-wake contamination)",
229
+ )
230
+ p.add_argument(
231
+ "--max-dt",
232
+ type=float,
233
+ default=DEFAULT_MAX_DT,
234
+ metavar="SEC",
235
+ help="diss->chi window pairing tolerance (default 0.5); applies to "
236
+ "the legacy re-pairing when the chi product has no epsilon_paired "
237
+ "(stored pairings are consumed as-is)",
238
+ )
239
+ p.add_argument(
240
+ "--ri-n2-span",
241
+ type=float,
242
+ default=DEFAULT_RI_N2_SPAN,
243
+ metavar="M",
244
+ help="vertical span for the Ri_g numerator N2 (default "
245
+ "4 m ~ 2 ADCP bins; 0 uses the chi-window N2)",
246
+ )
247
+ p.add_argument(
248
+ "--min-run",
249
+ type=float,
250
+ default=10,
251
+ metavar="N",
252
+ help="Galbraith-Kelley coherence: an overturn counts as "
253
+ "resolved only when >= N consecutive same-sign "
254
+ "displacements occur in the window (default 10 slow "
255
+ "samples ~ 10 cm at typical fall speed; noise flips "
256
+ "sign every 1-2)",
257
+ )
258
+ p.add_argument(
259
+ "--keep-truncated",
260
+ action="store_true",
261
+ help="keep window-edge-truncated overturns in R_OT "
262
+ "(default: excluded — a clipped L_T is only a lower "
263
+ "bound, biasing R_OT high)",
264
+ )
265
+ p.add_argument(
266
+ "--max-profiles",
267
+ type=int,
268
+ default=0,
269
+ metavar="N",
270
+ help="sweep only the first N profiles (0 = all; for quick looks)",
271
+ )
272
+ p.add_argument(
273
+ "--no-qc-flags",
274
+ dest="qc_flags",
275
+ action="store_false",
276
+ default=True,
277
+ help="ignore the pipeline's qc_drop_* hotel flags",
278
+ )
279
+ add_output_arguments(p)
280
+
281
+
282
+ # --------------------------------------------------------------------------- #
283
+ # sweep
284
+ # --------------------------------------------------------------------------- #
285
+
286
+
287
+ def _window_means(
288
+ win_times: np.ndarray,
289
+ half_width: float,
290
+ t: np.ndarray,
291
+ arrays: dict[str, np.ndarray],
292
+ ) -> dict[str, np.ndarray]:
293
+ """NaN-aware means of slow-grid *arrays* over each window (same time base)."""
294
+ out = {k: np.full(win_times.size, np.nan) for k in arrays}
295
+ for j, tau in enumerate(win_times):
296
+ sel = np.abs(t - tau) <= half_width
297
+ if not np.any(sel):
298
+ continue
299
+ for k, arr in arrays.items():
300
+ w = arr[sel]
301
+ w = w[np.isfinite(w)]
302
+ if w.size:
303
+ out[k][j] = float(np.mean(w))
304
+ return out
305
+
306
+
307
+ def _pair_indices(
308
+ src_t: np.ndarray,
309
+ dst_t: np.ndarray,
310
+ max_dt: float | None,
311
+ valid: np.ndarray | None = None,
312
+ ) -> np.ndarray:
313
+ """Index of the nearest src window per dst window (-1 = unpaired).
314
+
315
+ A single index pairing (rather than per-variable ``pair_nearest`` calls)
316
+ guarantees every diss column — epsilon, its QC flag — comes from the SAME
317
+ source window. *valid* restricts the candidate source windows (e.g. to
318
+ finite-epsilon ones), matching ``pair_nearest``'s own NaN-source rule: a
319
+ QC'd-out window must not shadow a valid neighbor within ``max_dt``.
320
+ """
321
+ from odas_tpw.processing.mixing import pair_nearest
322
+
323
+ keep = (
324
+ np.flatnonzero(np.asarray(valid, dtype=bool))
325
+ if valid is not None
326
+ else np.arange(src_t.size)
327
+ )
328
+ idx = np.full(dst_t.size, -1, dtype=np.intp)
329
+ if keep.size == 0:
330
+ return idx
331
+ idxf = pair_nearest(src_t[keep], keep.astype(float), dst_t, max_dt)
332
+ ok = np.isfinite(idxf)
333
+ idx[ok] = idxf[ok].astype(np.intp)
334
+ return idx
335
+
336
+
337
+ def _gather(values: np.ndarray, idx: np.ndarray) -> np.ndarray:
338
+ """values[idx] with -1 mapped to NaN."""
339
+ out = np.full(idx.size, np.nan)
340
+ ok = idx >= 0
341
+ out[ok] = np.asarray(values, dtype=float)[idx[ok]]
342
+ return out
343
+
344
+
345
+ def _epoch(var: xr.DataArray) -> np.ndarray:
346
+ """Decoded-time variable -> float epoch seconds (NaT -> NaN)."""
347
+ return _dt64_to_epoch_s(np.atleast_1d(var.values))
348
+
349
+
350
+ def _sweep_profile(
351
+ chi_path: str,
352
+ diss_path: str | None,
353
+ prof_path: str | None,
354
+ args: argparse.Namespace,
355
+ cols: dict[str, list],
356
+ counters: dict[str, int],
357
+ ) -> None:
358
+ """Append one profile's window-level columns onto *cols*."""
359
+ import gsw
360
+
361
+ from odas_tpw.processing import mixing, thorpe
362
+
363
+ with xr.open_dataset(chi_path) as c:
364
+ ct = _epoch(c["t"])
365
+ n = ct.size
366
+ lat = float(np.atleast_1d(c["lat"].values)[0])
367
+ lon = float(np.atleast_1d(c["lon"].values)[0])
368
+ chi = np.asarray(c["chiMean"].values, dtype=float)
369
+ n2_win = np.asarray(c["N2"].values, dtype=float)
370
+ dTdz = np.asarray(c["dTdz"].values, dtype=float)
371
+ nu = np.asarray(c["nu"].values, dtype=float)
372
+ P = np.asarray(c["P_mean"].values, dtype=float)
373
+ T_mean = np.asarray(c["T_mean"].values, dtype=float)
374
+ speed = np.asarray(c["speed"].values, dtype=float)
375
+ qc_chi = (
376
+ np.asarray(c["qc_drop_chi"].values, dtype=float)
377
+ if "qc_drop_chi" in c.variables
378
+ else np.zeros(n)
379
+ )
380
+ # Prefer the pipeline's stored pairing (epsilon_paired): the figure is
381
+ # then built from the SAME epsilon that entered the product's Gamma,
382
+ # and the diss product is not needed at all.
383
+ eps_stored = "epsilon_paired" in c.variables
384
+ eps = np.asarray(c["epsilon_paired"].values, dtype=float) if eps_stored else None
385
+ qc_eps = np.zeros(n)
386
+ if diss_path is not None:
387
+ with xr.open_dataset(diss_path) as d:
388
+ eps_src = np.asarray(d["epsilonMean"].values, dtype=float)
389
+ # With a stored pairing, QC flags are paired at the PIPELINE's
390
+ # tolerance (pair_nearest's median-spacing default, max_dt=None)
391
+ # so a window whose stored eps paired looser than --max-dt still
392
+ # gets its qc_drop_epsilon; --max-dt governs only the legacy
393
+ # re-pair path.
394
+ idx = _pair_indices(
395
+ _epoch(d["t"]),
396
+ ct,
397
+ args.max_dt if eps is None else None,
398
+ valid=np.isfinite(eps_src),
399
+ )
400
+ if eps is None: # older chi product without epsilon_paired
401
+ counters["re_pair_fallback"] += 1
402
+ idx_eps = _pair_indices(_epoch(d["t"]), ct, args.max_dt, valid=np.isfinite(eps_src))
403
+ eps = _gather(eps_src, idx_eps)
404
+ if "qc_drop_epsilon" in d.variables:
405
+ qc_eps = _gather(d["qc_drop_epsilon"].values, idx)
406
+ if eps is None:
407
+ # No stored pairing and no diss file: epsilon-dependent quantities
408
+ # (Gamma, Re_b, R_OT) are unavailable for this profile.
409
+ counters["no_epsilon"] += 1
410
+ eps = np.full(n, np.nan)
411
+ elif eps_stored and not np.isfinite(eps).any():
412
+ # A stored-but-empty pairing (e.g. every diss window QC'd out) is
413
+ # just as epsilon-less; count it so the all-profiles guard can fire.
414
+ # (A legacy re-pair that found nothing is NOT counted: there the
415
+ # user's --max-dt did the excluding, and an all-NaN column is the
416
+ # honest result of that experiment.)
417
+ counters["no_epsilon"] += 1
418
+
419
+ half = args.sort_window / 2.0
420
+ lt_s = np.full(n, np.nan)
421
+ lt_t = np.full(n, np.nan)
422
+ n2p_s = np.full(n, np.nan)
423
+ n2p_t = np.full(n, np.nan)
424
+ edge_s = np.zeros(n)
425
+ edge_t = np.zeros(n)
426
+ run_s = np.zeros(n)
427
+ run_t = np.zeros(n)
428
+ n2_ri = np.array(n2_win, dtype=float, copy=True)
429
+ sp_mean = np.full(n, np.nan)
430
+ if prof_path is not None:
431
+ with xr.open_dataset(prof_path) as p:
432
+ ts = _epoch(p["t_slow"])
433
+ Ps = np.asarray(p["P"].values, dtype=float)
434
+ have = {v for v in ("sigma0", "T1", "JAC_T", "SA", "CT", "SP") if v in p.variables}
435
+ slow = {v: np.asarray(p[v].values, dtype=float) for v in have}
436
+
437
+ means = _window_means(
438
+ ct,
439
+ half,
440
+ ts,
441
+ {k: slow[k] for k in ("SA", "CT", "SP") if k in slow},
442
+ )
443
+ sp_mean = means.get("SP", sp_mean)
444
+ g = gsw.grav(lat, P)
445
+ if "sigma0" in slow:
446
+ res_s = thorpe.window_thorpe(
447
+ ct, half, ts, Ps, slow["sigma0"], increasing_down=True, lat=lat
448
+ )
449
+ lt_s = res_s.L_T
450
+ edge_s = res_s.edge_truncated.astype(float)
451
+ run_s = res_s.max_run.astype(float)
452
+ sig_means = _window_means(ct, half, ts, {"s0": slow["sigma0"]})
453
+ rho0 = 1000.0 + sig_means["s0"]
454
+ n2p_s = thorpe.patch_n2(res_s.rms_fluct, lt_s, g / rho0)
455
+ if "T1" in slow:
456
+ res_t = thorpe.window_thorpe(
457
+ ct, half, ts, Ps, slow["T1"], increasing_down=False, lat=lat
458
+ )
459
+ lt_t = res_t.L_T
460
+ edge_t = res_t.edge_truncated.astype(float)
461
+ run_t = res_t.max_run.astype(float)
462
+ if "SA" in means and "CT" in means:
463
+ alpha = gsw.alpha(means["SA"], means["CT"], P)
464
+ n2p_t = thorpe.patch_n2(res_t.rms_fluct, lt_t, alpha * g)
465
+
466
+ # Matched-scale N2 for the Ri_g numerator: Thorpe-sorted background
467
+ # over ~the ADCP differencing span, converted to a time half-width
468
+ # via the cast's median fall speed.
469
+ if args.adcp and args.ri_n2_span > 0 and "JAC_T" in slow:
470
+ w_med = float(np.nanmedian(speed))
471
+ if np.isfinite(w_med) and w_med > 0:
472
+ strat = mixing.sorted_stratification(
473
+ ct,
474
+ (args.ri_n2_span / 2.0) / w_med,
475
+ ts,
476
+ Ps,
477
+ slow["JAC_T"],
478
+ S=slow.get("SP"),
479
+ lat=lat,
480
+ lon=lon,
481
+ )
482
+ n2_ri = strat.N2
483
+ else:
484
+ counters["no_profiles_file"] += 1
485
+
486
+ # Gamma / Cox from UNMASKED components (see module docstring).
487
+ from odas_tpw.scor160.ocean import kappa_T
488
+
489
+ with np.errstate(divide="ignore", invalid="ignore"):
490
+ ok_g = (
491
+ np.isfinite(chi)
492
+ & (chi > 0)
493
+ & np.isfinite(eps)
494
+ & (eps > 0)
495
+ & np.isfinite(n2_win)
496
+ & (n2_win > 0)
497
+ & np.isfinite(dTdz)
498
+ & (dTdz != 0)
499
+ )
500
+ gamma = np.where(
501
+ ok_g,
502
+ n2_win * chi / (2.0 * eps * np.where(ok_g, dTdz, 1.0) ** 2),
503
+ np.nan,
504
+ )
505
+ sal = np.where(np.isfinite(sp_mean), sp_mean, 35.0)
506
+ cox = thorpe.cox_number(chi, kappa_T(T_mean, sal, P), dTdz)
507
+
508
+ cols["epoch"].append(ct)
509
+ cols["P"].append(P)
510
+ cols["lat"].append(np.full(n, lat))
511
+ cols["lon"].append(np.full(n, lon))
512
+ cols["eps"].append(eps)
513
+ cols["nu"].append(nu)
514
+ cols["chi"].append(chi)
515
+ cols["N2_win"].append(n2_win)
516
+ cols["N2_ri"].append(np.asarray(n2_ri, dtype=float))
517
+ cols["dTdz"].append(dTdz)
518
+ cols["Gamma"].append(gamma)
519
+ cols["Cox"].append(cox)
520
+ cols["LT_sigma"].append(np.asarray(lt_s, dtype=float))
521
+ cols["LT_temp"].append(np.asarray(lt_t, dtype=float))
522
+ cols["N2p_sigma"].append(np.asarray(n2p_s, dtype=float))
523
+ cols["N2p_temp"].append(np.asarray(n2p_t, dtype=float))
524
+ cols["edge_sigma"].append(edge_s)
525
+ cols["edge_temp"].append(edge_t)
526
+ cols["run_sigma"].append(run_s)
527
+ cols["run_temp"].append(run_t)
528
+ # Union of the two products' hotel-QC bitfields. Missing/unpaired flags
529
+ # count as clean: an unpaired diss window already has eps=NaN, so its
530
+ # Gamma never survives to the figure anyway.
531
+ cols["qc"].append(
532
+ np.where(np.isfinite(qc_chi), qc_chi, 0.0) + np.where(np.isfinite(qc_eps), qc_eps, 0.0)
533
+ )
534
+
535
+
536
+ def sweep(args: argparse.Namespace) -> _Table:
537
+ """Assemble the window-level table for the whole run (+ ADCP S2/Ri_g)."""
538
+ import gsw
539
+
540
+ from odas_tpw.perturb import adcp as adcp_mod
541
+ from odas_tpw.processing import thorpe
542
+
543
+ chi_dir = resolve.resolve_for_args(args, "chi")
544
+ if chi_dir is None:
545
+ raise SystemExit(
546
+ "gamma-scaling needs the per-profile chi_NN product; run the "
547
+ "pipeline with chi enabled first"
548
+ )
549
+ # diss is optional: chi products written by pipelines that store
550
+ # epsilon_paired carry Gamma's epsilon themselves; diss is then only
551
+ # consulted for its qc_drop_epsilon flags (and as the epsilon fallback
552
+ # for older chi products).
553
+ diss_dir = resolve.resolve_for_args(args, "diss", optional=True)
554
+ prof_dir = resolve.resolve_for_args(args, "profiles", optional=True)
555
+ if prof_dir is None:
556
+ print(
557
+ "no profiles_NN dir: Thorpe scales (R_OT) unavailable; Re_b uses the chi-window N2",
558
+ file=sys.stderr,
559
+ )
560
+
561
+ files = sorted(os.path.basename(f) for f in glob.glob(os.path.join(chi_dir, "*.nc")))
562
+ if args.max_profiles > 0:
563
+ files = files[: args.max_profiles]
564
+ if not files:
565
+ raise SystemExit(f"no per-profile chi files under {chi_dir}")
566
+
567
+ cols: dict[str, list] = {
568
+ k: []
569
+ for k in (
570
+ "epoch",
571
+ "P",
572
+ "lat",
573
+ "lon",
574
+ "eps",
575
+ "nu",
576
+ "chi",
577
+ "N2_win",
578
+ "N2_ri",
579
+ "dTdz",
580
+ "Gamma",
581
+ "Cox",
582
+ "LT_sigma",
583
+ "LT_temp",
584
+ "N2p_sigma",
585
+ "N2p_temp",
586
+ "edge_sigma",
587
+ "edge_temp",
588
+ "run_sigma",
589
+ "run_temp",
590
+ "qc",
591
+ )
592
+ }
593
+ counters = {"no_profiles_file": 0, "re_pair_fallback": 0, "no_epsilon": 0}
594
+ n_done = 0
595
+ for f in files:
596
+ diss_path = (
597
+ os.path.join(diss_dir, f)
598
+ if diss_dir is not None and os.path.exists(os.path.join(diss_dir, f))
599
+ else None
600
+ )
601
+ prof_path = (
602
+ os.path.join(prof_dir, f)
603
+ if prof_dir is not None and os.path.exists(os.path.join(prof_dir, f))
604
+ else None
605
+ )
606
+ _sweep_profile(os.path.join(chi_dir, f), diss_path, prof_path, args, cols, counters)
607
+ n_done += 1
608
+
609
+ if n_done == 0:
610
+ raise SystemExit("no per-profile chi files could be swept")
611
+ if counters["no_epsilon"] == n_done:
612
+ raise SystemExit(
613
+ "no epsilon available for any profile (no usable epsilon_paired "
614
+ "in the chi product and no diss_NN product to fall back on) — "
615
+ "run the pipeline's diss stage, or a pipeline that stores "
616
+ "epsilon_paired"
617
+ )
618
+ table = _Table(
619
+ cols={k: np.concatenate(v) if v else np.empty(0) for k, v in cols.items()},
620
+ n_profiles=n_done,
621
+ )
622
+ if table.n == 0:
623
+ raise SystemExit("the chi product contains no dissipation windows")
624
+ for name, count in counters.items():
625
+ if count:
626
+ print(f"note: {count} profile(s) skipped/degraded ({name})", file=sys.stderr)
627
+
628
+ # Route selection + patch-N2 fallback (below the floor: background N2).
629
+ floor_s = (
630
+ args.lt_floor_density
631
+ if args.lt_floor_density is not None
632
+ else thorpe.DEFAULT_LT_FLOOR_DENSITY
633
+ )
634
+ floor_t = (
635
+ args.lt_floor_temperature
636
+ if args.lt_floor_temperature is not None
637
+ else thorpe.DEFAULT_LT_FLOOR_TEMPERATURE
638
+ )
639
+ if args.route == "density":
640
+ lt, n2p, floor = table["LT_sigma"], table["N2p_sigma"], floor_s
641
+ edge, run = table["edge_sigma"], table["run_sigma"]
642
+ else:
643
+ lt, n2p, floor = table["LT_temp"], table["N2p_temp"], floor_t
644
+ edge, run = table["edge_temp"], table["run_temp"]
645
+ # A window's overturn is "resolved" (usable for L_T / N2_patch) when the
646
+ # scale clears the route's noise floor, the displacement block is coherent
647
+ # (Galbraith-Kelley run length — uncorrelated noise flips sign every 1-2
648
+ # samples), and the overturn is NOT clipped by the sort window (a
649
+ # truncated L_T is only a lower bound, which biases R_OT high; in weakly
650
+ # stratified water saturation at ~the window span is the signature of
651
+ # noise, not of a measured overturn).
652
+ resolved = np.isfinite(lt) & (lt >= floor) & np.isfinite(n2p) & (n2p > 0)
653
+ resolved &= run >= args.min_run
654
+ if not args.keep_truncated:
655
+ resolved &= edge == 0
656
+ n2_bg = table["N2_win"]
657
+ n2_scale = np.where(resolved, n2p, np.where(np.isfinite(n2_bg) & (n2_bg > 0), n2_bg, np.nan))
658
+ table.cols["LT"] = np.where(resolved, lt, np.nan)
659
+ table.cols["LT_floor_sigma"] = np.full(table.n, floor_s)
660
+ table.cols["LT_floor_temp"] = np.full(table.n, floor_t)
661
+ table.cols["resolved"] = resolved.astype(float)
662
+ table.cols["N2_scale"] = n2_scale
663
+ table.cols["Re_b"] = thorpe.reynolds_buoyancy(table["eps"], table["nu"], n2_scale)
664
+ lo = thorpe.ozmidov(table["eps"], n2_scale)
665
+ table.cols["R_OT"] = thorpe.r_ot(lo, table.cols["LT"])
666
+
667
+ # Per-sonar S2 -> Ri_g (matched-scale numerator; sonars never blended).
668
+ depth_m = np.asarray(-gsw.z_from_p(table["P"], table["lat"]), dtype=float)
669
+ for k, path in enumerate(args.adcp or []):
670
+ try:
671
+ src = adcp_mod.read_codas(path, min_pg=args.min_pg)
672
+ except ValueError as exc: # corrupt file / min_pg without pg / ...
673
+ raise SystemExit(str(exc)) from exc
674
+ name = src.name
675
+ if name in table.rig:
676
+ # Two --adcp files with the same stem (e.g. two legs' wh300.nc)
677
+ # must not silently overwrite each other's panel.
678
+ name = f"{name}#{k + 1}"
679
+ print(f"note: duplicate sonar name; labeling {path} as {name!r}", file=sys.stderr)
680
+ try:
681
+ ws = adcp_mod.window_shear(
682
+ src, table["epoch"], depth_m, time_tolerance=args.time_tolerance
683
+ )
684
+ except ValueError as exc: # bad tolerance / unusable geometry
685
+ raise SystemExit(str(exc)) from exc
686
+ n2r = table["N2_ri"]
687
+ with np.errstate(divide="ignore", invalid="ignore"):
688
+ ok = np.isfinite(n2r) & (n2r > 0) & np.isfinite(ws.S2) & (ws.S2 > 0)
689
+ table.rig[name] = np.where(ok, n2r / np.where(ok, ws.S2, 1.0), np.nan)
690
+ table.s2[name] = ws.S2
691
+ return table
692
+
693
+
694
+ # --------------------------------------------------------------------------- #
695
+ # QC + section coloring
696
+ # --------------------------------------------------------------------------- #
697
+
698
+
699
+ def qc_mask(table: _Table, args: argparse.Namespace) -> np.ndarray:
700
+ """Paper-default QC: Re_b, Cox, depth, qc_drop flags, positive Gamma."""
701
+ ok = np.isfinite(table["Gamma"]) & (table["Gamma"] > 0)
702
+ ok &= table["P"] >= args.min_depth
703
+ if args.min_reb > 0:
704
+ ok &= np.isfinite(table["Re_b"]) & (table["Re_b"] > args.min_reb)
705
+ if args.min_cox > 0:
706
+ ok &= np.isfinite(table["Cox"]) & (table["Cox"] > args.min_cox)
707
+ if args.qc_flags:
708
+ ok &= table["qc"] == 0
709
+ return np.asarray(ok)
710
+
711
+
712
+ def _section_colors(table: _Table, args: argparse.Namespace) -> tuple[np.ndarray, list[str]]:
713
+ """(per-window section index, section names); index -1 = unsectioned.
714
+
715
+ Where sections overlap, the **narrowest** time window wins. Files often
716
+ lead with an umbrella section (e.g. the ARCTERX example's unbounded
717
+ ``full_timeseries``); with first-wins ordering it would swallow every
718
+ window and render the figure monochrome, so specific sections take
719
+ precedence and the umbrella only catches windows nothing narrower claims.
720
+ """
721
+ idx = np.full(table.n, -1, dtype=int)
722
+ if not args.sections:
723
+ return idx, []
724
+ sections = load_sections(args.sections)
725
+ if args.select:
726
+ sections = select_sections(sections, args.select)
727
+ epoch = table["epoch"]
728
+
729
+ def _width(i: int) -> float:
730
+ sec = sections[i]
731
+ if sec.start is None or sec.stop is None:
732
+ return np.inf
733
+ return float((sec.stop - sec.start) / np.timedelta64(1, "s"))
734
+
735
+ for i in sorted(range(len(sections)), key=_width):
736
+ sec = sections[i]
737
+ m = np.ones(table.n, dtype=bool)
738
+ if sec.start is not None:
739
+ m &= epoch >= float(xaxis.to_epoch_seconds(np.array([sec.start]))[0])
740
+ if sec.stop is not None:
741
+ m &= epoch <= float(xaxis.to_epoch_seconds(np.array([sec.stop]))[0])
742
+ idx[m & (idx < 0)] = i
743
+ return idx, [s.name for s in sections]
744
+
745
+
746
+ # --------------------------------------------------------------------------- #
747
+ # figures
748
+ # --------------------------------------------------------------------------- #
749
+
750
+
751
+ def _log_bins(x: np.ndarray, n_bins: int | None = None) -> np.ndarray:
752
+ """Equispaced log bins across the robust (1-99%) range of *x*.
753
+
754
+ The bin count adapts to the sample size (sqrt rule, clipped to 6..14) so
755
+ sparse panels still accumulate the >= 5 points per bin the median/whisker
756
+ overlay needs.
757
+ """
758
+ fin = x[np.isfinite(x) & (x > 0)]
759
+ if n_bins is None:
760
+ n_bins = int(np.clip(np.sqrt(fin.size), 6, 14)) if fin.size else 6
761
+ lo, hi = np.percentile(fin, [1.0, 99.0]) if fin.size else (np.nan, np.nan)
762
+ if not (np.isfinite(lo) and np.isfinite(hi)) or lo <= 0 or hi <= lo:
763
+ lo, hi = 1e-1, 1e1
764
+ return np.logspace(np.log10(lo), np.log10(hi), n_bins + 1)
765
+
766
+
767
+ def _binned_median(ax: Axes, x: np.ndarray, y: np.ndarray, bins: np.ndarray) -> None:
768
+ """Gray squares: median y per log-x bin, 5/95% whiskers (paper style)."""
769
+ for k in range(bins.size - 1):
770
+ m = (x >= bins[k]) & (x < bins[k + 1]) & np.isfinite(y)
771
+ if np.count_nonzero(m) < 5:
772
+ continue
773
+ xc = np.sqrt(bins[k] * bins[k + 1])
774
+ med = float(np.median(y[m]))
775
+ p5, p95 = np.percentile(y[m], [5.0, 95.0])
776
+ ax.errorbar(
777
+ xc,
778
+ med,
779
+ yerr=[[med - p5], [p95 - med]],
780
+ fmt="s",
781
+ color="0.25",
782
+ mfc="0.85",
783
+ mec="0.25",
784
+ ms=5,
785
+ elinewidth=0.9,
786
+ capsize=2,
787
+ zorder=5,
788
+ )
789
+
790
+
791
+ def _marginal_pdf(
792
+ ax: Axes, data: np.ndarray, bins: np.ndarray, color: Any, *, vertical: bool = False
793
+ ) -> None:
794
+ """Filled log-bin density on a marginal axis (paper-style pdfs)."""
795
+ d = data[np.isfinite(data) & (data > 0)]
796
+ if d.size < 5:
797
+ return
798
+ counts, edges = np.histogram(d, bins=bins)
799
+ dens = counts / max(counts.sum(), 1) / np.diff(np.log10(edges))
800
+ if vertical:
801
+ ax.stairs(dens, edges, orientation="horizontal", fill=True, color=color, alpha=0.35)
802
+ else:
803
+ ax.stairs(dens, edges, fill=True, color=color, alpha=0.35)
804
+
805
+
806
+ def _slope_guides(
807
+ ax: Axes, x: np.ndarray, y: np.ndarray, guides: tuple[tuple[float, str], ...]
808
+ ) -> None:
809
+ """Dashed slope-labeled reference lines anchored at the data median."""
810
+ fin = np.isfinite(x) & np.isfinite(y) & (x > 0) & (y > 0)
811
+ if np.count_nonzero(fin) < 5:
812
+ return
813
+ x0 = float(np.exp(np.median(np.log(x[fin]))))
814
+ y0 = float(np.exp(np.median(np.log(y[fin]))))
815
+ lo, hi = np.nanpercentile(x[fin], [1.0, 99.0])
816
+ xs = np.logspace(np.log10(lo), np.log10(hi), 32)
817
+ for slope, label in guides:
818
+ ax.plot(xs, y0 * (xs / x0) ** slope, ls="--", lw=0.9, color="0.3", zorder=4)
819
+ ax.annotate(
820
+ label,
821
+ (xs[-1], y0 * (xs[-1] / x0) ** slope),
822
+ fontsize=8,
823
+ color="0.3",
824
+ textcoords="offset points",
825
+ xytext=(3, 0),
826
+ )
827
+
828
+
829
+ _PANEL_XLABEL = {
830
+ "R_OT": r"$R_{OT} = L_O / L_T$",
831
+ "Re_b": r"$\mathrm{Re}_b = \epsilon\,/\,(\nu N^2_{patch})$",
832
+ "Ri_g": r"estimated local $\mathrm{Ri}_g = N^2 / S^2$",
833
+ }
834
+
835
+
836
+ def _fig_gamma(table: _Table, args: argparse.Namespace) -> Figure | None:
837
+ """The Fig.-5 analog: Gamma vs R_OT / Re_b / Ri_g (one panel per sonar)."""
838
+ import matplotlib.pyplot as plt
839
+ from matplotlib.lines import Line2D
840
+ from matplotlib.ticker import NullFormatter
841
+
842
+ ok = qc_mask(table, args)
843
+ gamma = np.where(ok, table["Gamma"], np.nan)
844
+ panels: list[tuple[str, str, np.ndarray]] = [
845
+ ("R_OT", _PANEL_XLABEL["R_OT"], np.where(ok, table["R_OT"], np.nan)),
846
+ ("Re_b", _PANEL_XLABEL["Re_b"], np.where(ok, table["Re_b"], np.nan)),
847
+ ]
848
+ for name, rig in table.rig.items():
849
+ panels.append(("Ri_g", f"{_PANEL_XLABEL['Ri_g']} [{name}]", np.where(ok, rig, np.nan)))
850
+ panels = [p for p in panels if np.count_nonzero(np.isfinite(p[2]) & np.isfinite(gamma)) >= 5]
851
+ if not panels:
852
+ print("gamma-scaling: no QC-passing windows to plot", file=sys.stderr)
853
+ return None
854
+
855
+ sec_idx, sec_names = _section_colors(table, args)
856
+ cmap = plt.get_cmap("tab10")
857
+ if len(sec_names) > 10:
858
+ print(
859
+ f"gamma-scaling: {len(sec_names)} sections but only 10 distinct "
860
+ "colors (tab10 wraps); consider --select",
861
+ file=sys.stderr,
862
+ )
863
+ sec_plotted = np.zeros(len(sec_names), dtype=bool) # for the legend
864
+
865
+ n_p = len(panels)
866
+ figsize = args.figsize or (4.6 * n_p, 5.0)
867
+ fig = plt.figure(figsize=figsize)
868
+ outer = fig.add_gridspec(1, n_p, wspace=0.32, left=0.07, right=0.97, top=0.86, bottom=0.13)
869
+ ylims = _gamma_limits(gamma)
870
+ ybins = np.logspace(np.log10(ylims[0]), np.log10(ylims[1]), 25)
871
+
872
+ for i, (kind, xlabel, x) in enumerate(panels):
873
+ inner = outer[i].subgridspec(
874
+ 2, 2, width_ratios=(4, 1), height_ratios=(1, 4), hspace=0.06, wspace=0.06
875
+ )
876
+ ax = fig.add_subplot(inner[1, 0])
877
+ ax_top = fig.add_subplot(inner[0, 0], sharex=ax)
878
+ ax_right = fig.add_subplot(inner[1, 1], sharey=ax)
879
+ fin = np.isfinite(x) & np.isfinite(gamma)
880
+ if sec_names:
881
+ un = sec_idx[fin & (sec_idx < 0)]
882
+ if un.size:
883
+ m = fin & (sec_idx < 0)
884
+ ax.scatter(x[m], gamma[m], s=5, c="0.75", alpha=0.4, lw=0, rasterized=True)
885
+ for si in range(len(sec_names)):
886
+ m = fin & (sec_idx == si)
887
+ if not m.any():
888
+ continue
889
+ sec_plotted[si] = True
890
+ color = cmap(si % 10)
891
+ ax.scatter(x[m], gamma[m], s=5, color=color, alpha=0.45, lw=0, rasterized=True)
892
+ _marginal_pdf(ax_top, x[m], _log_bins(x[fin]), color)
893
+ _marginal_pdf(ax_right, gamma[m], ybins, color, vertical=True)
894
+ else:
895
+ ax.scatter(x[fin], gamma[fin], s=5, color="tab:blue", alpha=0.35, lw=0, rasterized=True)
896
+ _marginal_pdf(ax_top, x[fin], _log_bins(x[fin]), "0.5")
897
+ _marginal_pdf(ax_right, gamma[fin], ybins, "0.5", vertical=True)
898
+ bins = _log_bins(x[fin])
899
+ _binned_median(ax, x, gamma, bins)
900
+ _slope_guides(ax, x, gamma, _GUIDES[kind])
901
+ ax.set_xscale("log")
902
+ ax.set_yscale("log")
903
+ ax.set_ylim(*ylims)
904
+ # Expand x to whole decades: a sub-decade log axis draws labeled
905
+ # minor ticks that pile into an unreadable jumble.
906
+ xf = x[fin & (x > 0)]
907
+ if xf.size:
908
+ ax.set_xlim(10 ** np.floor(np.log10(xf.min())), 10 ** np.ceil(np.log10(xf.max())))
909
+ # Whole-decade limits alone don't stop the label pileup: matplotlib
910
+ # still labels minor ticks when the span is <= 1 decade.
911
+ ax.xaxis.set_minor_formatter(NullFormatter())
912
+ ax.set_xlabel(xlabel)
913
+ if kind == "R_OT":
914
+ # R_OT exists only for resolved (floor/coherence/truncation-
915
+ # gated) overturns — say how selective that gate was, so this
916
+ # panel is not read as the full window population.
917
+ n_rot = int(np.count_nonzero(fin))
918
+ n_ok_all = int(np.count_nonzero(ok))
919
+ frac = n_rot / n_ok_all if n_ok_all else 0.0
920
+ ax.text(
921
+ 0.03,
922
+ 0.03,
923
+ f"{grouped(n_rot)} resolved overturns ({frac:.0%} of QC-pass)",
924
+ transform=ax.transAxes,
925
+ fontsize=7,
926
+ color="0.35",
927
+ )
928
+ if i == 0:
929
+ ax.set_ylabel(r"$\Gamma = N^2\chi\,/\,(2\epsilon\,(dT/dz)^2)$")
930
+ ax.grid(True, which="major", color="0.85", lw=0.5)
931
+ ax.tick_params(labelsize=9)
932
+ # sharex/sharey already propagate the log scales onto the marginals;
933
+ # their own value axes are hidden entirely.
934
+ ax_top.axis("off")
935
+ ax_right.axis("off")
936
+
937
+ if sec_names and sec_plotted.any():
938
+ # Only sections that actually placed points — an empty legend entry
939
+ # (e.g. an umbrella section fully shadowed by narrower ones) would
940
+ # mislabel colors that never appear.
941
+ handles = [
942
+ Line2D([], [], marker="o", ls="", color=cmap(i % 10), label=name)
943
+ for i, name in enumerate(sec_names)
944
+ if sec_plotted[i]
945
+ ]
946
+ fig.legend(handles=handles, loc="upper right", fontsize=8, ncols=2, frameon=False)
947
+ n_ok = int(np.count_nonzero(ok))
948
+ title_id = os.path.basename(os.path.normpath(args.root))
949
+ fig.suptitle(
950
+ getattr(args, "title", None)
951
+ or (
952
+ f"{title_id} — mixing-efficiency scaling — {table.n_profiles} casts, "
953
+ f"{grouped(n_ok)} QC-pass windows (of {grouped(table.n)}) — "
954
+ f"Thorpe route: {args.route}"
955
+ ),
956
+ fontsize=11,
957
+ )
958
+ return fig
959
+
960
+
961
+ def _gamma_limits(gamma: np.ndarray) -> tuple[float, float]:
962
+ """Paper axis (1e-3..1e1) stretched to cover the data quantiles."""
963
+ fin = gamma[np.isfinite(gamma) & (gamma > 0)]
964
+ lo, hi = 1e-3, 1e1
965
+ if fin.size:
966
+ lo = min(lo, 10 ** np.floor(np.log10(np.percentile(fin, 0.5))))
967
+ hi = max(hi, 10 ** np.ceil(np.log10(np.percentile(fin, 99.5))))
968
+ return lo, hi
969
+
970
+
971
+ def _fig_thorpe_compare(table: _Table, args: argparse.Namespace) -> Figure | None:
972
+ """sigma0-route vs T1-route L_T: the floor-setting comparison figure."""
973
+ import matplotlib.pyplot as plt
974
+
975
+ lt_s = table["LT_sigma"]
976
+ lt_t = table["LT_temp"]
977
+ both = np.isfinite(lt_s) & (lt_s > 0) & np.isfinite(lt_t) & (lt_t > 0)
978
+ if not both.any():
979
+ print(
980
+ "gamma-scaling: no windows with both Thorpe routes; route-comparison figure skipped",
981
+ file=sys.stderr,
982
+ )
983
+ return None
984
+ floor_s = float(table["LT_floor_sigma"][0])
985
+ floor_t = float(table["LT_floor_temp"][0])
986
+
987
+ fig, (ax, axh) = plt.subplots(1, 2, figsize=args.figsize or (10.0, 4.6))
988
+ ax.scatter(lt_t[both], lt_s[both], s=4, alpha=0.3, lw=0, color="tab:blue", rasterized=True)
989
+ lims = (1e-3, max(np.nanmax(lt_s[both]), np.nanmax(lt_t[both])) * 1.5)
990
+ ax.plot(lims, lims, color="0.3", lw=0.8, ls="-", label="1:1")
991
+ ax.axvline(floor_t, color="tab:red", ls="--", lw=0.9, label=f"T$_1$ floor {floor_t:g} m")
992
+ ax.axhline(
993
+ floor_s, color="tab:purple", ls="--", lw=0.9, label=rf"$\sigma_0$ floor {floor_s:g} m"
994
+ )
995
+ ax.set_xscale("log")
996
+ ax.set_yscale("log")
997
+ ax.set_xlim(*lims)
998
+ ax.set_ylim(*lims)
999
+ ax.set_xlabel(r"$L_T$ from FP07 $T_1$ (m)")
1000
+ ax.set_ylabel(r"$L_T$ from JAC $\sigma_0$ (m)")
1001
+ ax.grid(True, color="0.9", lw=0.5)
1002
+ ax.legend(fontsize=8, loc="upper left")
1003
+
1004
+ bins = np.logspace(-3, np.log10(lims[1]), 40)
1005
+ for arr, floor, color, label in (
1006
+ (lt_s, floor_s, "tab:purple", r"$\sigma_0$ route"),
1007
+ (lt_t, floor_t, "tab:red", r"$T_1$ route"),
1008
+ ):
1009
+ fin = arr[np.isfinite(arr) & (arr > 0)]
1010
+ resolved = float(np.mean(fin >= floor)) if fin.size else 0.0
1011
+ axh.hist(
1012
+ fin,
1013
+ bins=bins,
1014
+ histtype="step",
1015
+ color=color,
1016
+ label=f"{label}: {resolved:.0%} of overturned windows $\\geq$ floor",
1017
+ )
1018
+ axh.axvline(floor, color=color, ls="--", lw=0.9)
1019
+ axh.set_xscale("log")
1020
+ axh.set_xlabel(r"$L_T$ (m)")
1021
+ axh.set_ylabel("windows")
1022
+ axh.legend(fontsize=8)
1023
+ axh.grid(True, color="0.9", lw=0.5)
1024
+
1025
+ fallback = 1.0 - float(np.mean(table["resolved"]))
1026
+ title_id = os.path.basename(os.path.normpath(args.root))
1027
+ fig.suptitle(
1028
+ f"{title_id} — Thorpe-scale route comparison — route={args.route}: "
1029
+ f"{fallback:.0%} of windows fall back to background N$^2$ "
1030
+ f"(paper: 18%)",
1031
+ fontsize=11,
1032
+ )
1033
+ fig.tight_layout(rect=(0, 0, 1, 0.93))
1034
+ return fig
1035
+
1036
+
1037
+ # --------------------------------------------------------------------------- #
1038
+ # driver
1039
+ # --------------------------------------------------------------------------- #
1040
+
1041
+
1042
+ def build_figures(args: argparse.Namespace) -> Iterator[tuple[str, Any]]:
1043
+ """Yield the (stem, Figure) pairs: the scaling figure + route comparison."""
1044
+ args.root = resolve.require_root(args)
1045
+ if args.sort_window <= 0:
1046
+ raise SystemExit("--sort-window must be > 0")
1047
+ if args.select and not args.sections:
1048
+ raise SystemExit("--select requires --sections")
1049
+ table = sweep(args)
1050
+ _report(table, args)
1051
+ for stem, builder in (
1052
+ ("gamma_scaling", _fig_gamma),
1053
+ ("gamma_thorpe_compare", _fig_thorpe_compare),
1054
+ ):
1055
+ with close_new_figs_on_error():
1056
+ fig = builder(table, args)
1057
+ if fig is not None:
1058
+ yield stem, fig
1059
+
1060
+
1061
+ def _report(table: _Table, args: argparse.Namespace) -> None:
1062
+ """One-look sweep summary on stderr (coverage, QC attrition, fallback)."""
1063
+ ok = qc_mask(table, args)
1064
+ parts = [
1065
+ f"{table.n_profiles} casts, {grouped(table.n)} windows",
1066
+ f"QC pass {grouped(int(ok.sum()))}",
1067
+ ]
1068
+ for route, key in (("sigma0", "LT_sigma"), ("T1", "LT_temp")):
1069
+ lt = table[key]
1070
+ fin = np.isfinite(lt)
1071
+ if fin.any():
1072
+ parts.append(f"L_T[{route}] finite {float(fin.mean()):.0%}")
1073
+ edge_key = "edge_sigma" if args.route == "density" else "edge_temp"
1074
+ parts.append(
1075
+ f"patch-N2 resolved {float(np.mean(table['resolved'])):.0%} "
1076
+ f"(route {args.route}; {float(np.mean(table[edge_key] > 0)):.0%} "
1077
+ f"edge-truncated)"
1078
+ )
1079
+ for name, s2 in table.s2.items():
1080
+ cov = float(np.mean(np.isfinite(s2[ok]))) if ok.any() else 0.0
1081
+ parts.append(f"S2[{name}] covers {cov:.0%} of QC-pass windows")
1082
+ print("gamma-scaling: " + "; ".join(parts), file=sys.stderr)
1083
+
1084
+
1085
+ def run(args: argparse.Namespace) -> str:
1086
+ """Render the scaling figures; show or write PNGs."""
1087
+ args.root = resolve.require_root(args)
1088
+ display = args.out_dir is None and can_display()
1089
+ if display:
1090
+ with closing_figs(build_figures(args)) as figs:
1091
+ shown = save_or_show(figs, None, fig_dpi(args))
1092
+ return f"displayed {shown} figure(s)"
1093
+ out_dir = args.out_dir or args.root
1094
+ os.makedirs(out_dir, exist_ok=True)
1095
+ with closing_figs(build_figures(args)) as figs:
1096
+ save_or_show(figs, out_dir, fig_dpi(args))
1097
+ return str(out_dir)