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,652 @@
1
+ # Jun-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Depth-vs-x sections from binned (bin, profile) combos.
3
+
4
+ One engine drives four ``perturb-plot`` subcommands, each a :class:`ProductView`
5
+ bound to one product (the CLI dispatcher and the ``figure`` driver's presets
6
+ share these views):
7
+
8
+ * ``profiles`` -> ``combo_NN`` (binned slow channels: T1/T2, N2, dT/dz, ...)
9
+ * ``epsilon`` -> ``diss_combo_NN`` (epsilonMean / e_1 / e_2)
10
+ * ``chi`` -> ``chi_combo_NN`` (chiMean / chi_1 / chi_2)
11
+ * ``mixing`` -> ``chi_combo_NN`` (K_T / Gamma / K_rho)
12
+
13
+ These products are already a ``(bin, profile)`` grid with one ``lat``/``lon``/
14
+ ``stime`` per cast, so — unlike :mod:`scalar` (which grids a continuous
15
+ trajectory) — each profile is one *column*. The x-axis is computed per profile
16
+ via the shared :mod:`xaxis` kernel; columns are sorted by x and drawn one mesh
17
+ per x-cluster with blank gaps (:func:`layout.plot_columns`), so sparse/irregular
18
+ sampling stays honest rather than being stretched across unsampled water.
19
+
20
+ Vertical axis note: the binned products' ``bin`` coordinate is **depth in
21
+ meters** — profiles binned on pressure ``P`` are converted to depth at write
22
+ time via ``gsw.z_from_p`` (:mod:`binning`), so ``bin:units="m"`` is correct and
23
+ the y-axis reads ``Depth (m)``. Section parsing, ``--select``/``--xaxis``
24
+ override, ``--clim``, and display behavior are shared with ``scalar`` via
25
+ :mod:`sections`.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import contextlib
32
+ import locale
33
+ import os
34
+ from collections.abc import Iterator
35
+ from dataclasses import dataclass
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ import numpy as np
39
+ import xarray as xr
40
+
41
+ from odas_tpw.perturb import resolve
42
+ from odas_tpw.perturb.plot import diagnostics, grid, layout, xaxis
43
+ from odas_tpw.perturb.plot.sections import (
44
+ Section,
45
+ add_section_arguments,
46
+ can_display,
47
+ close_new_figs_on_error,
48
+ closing_figs,
49
+ fig_dpi,
50
+ grouped,
51
+ parse_clim,
52
+ resolve_sections,
53
+ safe_name,
54
+ save_or_show,
55
+ single_var_limit_guard,
56
+ var_label,
57
+ )
58
+
59
+ if TYPE_CHECKING:
60
+ from matplotlib.figure import Figure
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class _Product:
65
+ dir_prefix: str # primary latest_stage_dir prefix (combo / chi_combo / ...)
66
+ default_vars: tuple[str, ...]
67
+ qc_vars: tuple[str, ...] # companion qc_drop_* fields; union-ORed into the mask
68
+ label: str # user-facing name (subcommand / title / file stem)
69
+ default_ncols: int = 1 # column count when --ncols is not given
70
+ merge_dirs: tuple[str, ...] = () # extra combo dirs merged onto the primary grid
71
+
72
+
73
+ PRODUCTS: dict[str, _Product] = {
74
+ # Default profiles preset: a 3x3 overview (temperatures / water-mass /
75
+ # kinematics + stratification). Missing fields (e.g. SP/rho/sigma0 before a
76
+ # pipeline re-run) are silently dropped from the grid.
77
+ "profiles": _Product(
78
+ "combo",
79
+ ("JAC_T", "T1", "T2", "SP", "rho", "sigma0", "W_slow", "dTdz", "N2"),
80
+ (), "profiles", default_ncols=3,
81
+ ),
82
+ # Default epsilon preset: a 3-column overview (kinematics / window means /
83
+ # per-probe + combined dissipation / stratification).
84
+ "diss": _Product(
85
+ "diss_combo",
86
+ ("speed", "nu", "T_mean", "e_1", "e_2", "epsilonMean", "N2", "dTdz"),
87
+ ("qc_drop_epsilon",), "epsilon", default_ncols=3,
88
+ ),
89
+ # Default chi preset: a 3-column overview (kinematics / window means /
90
+ # per-probe + combined chi / stratification / QC flag).
91
+ "chi": _Product(
92
+ "chi_combo",
93
+ ("speed", "nu", "T_mean", "chi_1", "chi_2", "chiMean", "N2", "dTdz",
94
+ "qc_drop_chi"),
95
+ ("qc_drop_chi",), "chi", default_ncols=3,
96
+ ),
97
+ # Mixing draws from BOTH the chi combo (chi / K_T / K_rho / Gamma) and the
98
+ # diss combo (e_*), merged on their shared (bin, profile) grid. A cell is
99
+ # dropped if EITHER product's QC flags it (union of qc_drop_epsilon/chi).
100
+ "mixing": _Product(
101
+ "chi_combo",
102
+ ("e_1", "e_2", "epsilonMean", "chi_1", "chi_2", "chiMean",
103
+ "K_T", "K_rho", "Gamma"),
104
+ ("qc_drop_epsilon", "qc_drop_chi"), "mixing", default_ncols=3,
105
+ merge_dirs=("diss_combo",),
106
+ ),
107
+ }
108
+
109
+ # Per-variable cmocean colormap (default thermal).
110
+ _CMAP: dict[str, str] = {
111
+ "T1": "thermal", "T2": "thermal", "JAC_T": "thermal", "T_mean": "thermal",
112
+ "N2": "amp", "dTdz": "balance",
113
+ "epsilonMean": "thermal", "e_1": "thermal", "e_2": "thermal",
114
+ "chiMean": "thermal", "chi_1": "thermal", "chi_2": "thermal",
115
+ "K_T": "tempo", "K_rho": "tempo", "Gamma": "matter",
116
+ "Incl_X": "balance", "Incl_Y": "balance",
117
+ "CT": "thermal", "SP": "haline", "SA": "haline",
118
+ "sigma0": "dense", "rho": "dense",
119
+ }
120
+ # Log-scaled fields (span orders of magnitude, strictly positive).
121
+ _LOG_VARS: frozenset[str] = frozenset({
122
+ "epsilonMean", "e_1", "e_2", "chiMean", "chi_1", "chi_2",
123
+ "K_T", "K_rho", "Gamma",
124
+ })
125
+ # Log-scaled but sign-bearing (SymLogNorm): N2 is usually positive but can go
126
+ # negative in overturning/unstable patches, which LogNorm would hide as no-data.
127
+ _SYMLOG_VARS: frozenset[str] = frozenset({"N2"})
128
+ # Diverging fields centered on zero.
129
+ _DIVERGING: frozenset[str] = frozenset({"dTdz", "Incl_X", "Incl_Y"})
130
+
131
+ # Explicit colorbar labels overriding the CF long_name/units default
132
+ # (var_label) for the profile-product scalars. Mathtext renders the sub/
133
+ # superscripts and the dT/dz fraction; the degree sign stays outside mathtext
134
+ # as a literal Unicode char. Note dTdz is a *conservative*-temperature (theta)
135
+ # gradient (processing.mixing._stable_window), Thorpe-sorted over the background
136
+ # window -- the label keeps the familiar dT/dz form.
137
+ _CBAR_LABEL: dict[str, str] = {
138
+ "T1": r"$T_1$ (°C)",
139
+ "T2": r"$T_2$ (°C)",
140
+ "N2": r"$N^2$ (s$^{-2}$) (Thorpe-sorted)",
141
+ "dTdz": r"$\frac{dT}{dz}$ (°C m$^{-1}$) (+ downwards)",
142
+ "JAC_T": "JAC_T (°C)",
143
+ "JAC_C": "JAC_C (mS/cm)",
144
+ "CT": r"$\Theta$ (°C)",
145
+ "SP": "Salinity (PSU)",
146
+ "SA": "Salinity (g/kg)",
147
+ "sigma0": r"$\sigma_0$ (kg m$^{-3}$)",
148
+ "rho": r"$\rho$-1000 (kg m$^{-3}$) (in-situ)",
149
+ "P_dP": "P_dP (dbar)",
150
+ "Incl_X": "incl_X (°)",
151
+ "Incl_Y": "incl_Y (°)",
152
+ "Incl_T": "incl_T (°C)",
153
+ "W_slow": r"W_slow (dbar s$^{-1}$)",
154
+ # Epsilon (diss) product: Greek nu for viscosity, angle brackets for the
155
+ # window-mean temperature and combined epsilon (an overline renders faintly
156
+ # at colorbar-label size), and per-probe epsilons as ε_n (the "probe n" is
157
+ # redundant with the subscript).
158
+ "nu": r"$\nu$ (m$^2$ s$^{-1}$)",
159
+ "T_mean": r"$\langle T \rangle$ (°C)",
160
+ "e_1": r"$\epsilon_1$ (W kg$^{-1}$)",
161
+ "e_2": r"$\epsilon_2$ (W kg$^{-1}$)",
162
+ "epsilonMean": r"$\langle \epsilon \rangle$ (W kg$^{-1}$)",
163
+ # Chi (thermal-variance) and mixing product: per-probe χ_n, angle-bracket
164
+ # combined χ, the Osborn-Cox / Osborn diffusivities, and dimensionless Γ.
165
+ "chi_1": r"$\chi_1$ (K$^2$ s$^{-1}$)",
166
+ "chi_2": r"$\chi_2$ (K$^2$ s$^{-1}$)",
167
+ "chiMean": r"$\langle \chi \rangle$ (K$^2$ s$^{-1}$)",
168
+ "K_T": r"$K_T$ (m$^2$ s$^{-1}$)",
169
+ "K_rho": r"$K_\rho$ (m$^2$ s$^{-1}$)",
170
+ "Gamma": r"$\Gamma$",
171
+ }
172
+
173
+ # Variables whose colorbar reads with the smallest value at the top (axis
174
+ # inverted) rather than the matplotlib default (smallest at the bottom).
175
+ # SP/sigma0 mirror the scalar product (salinity/density rise with depth); nu
176
+ # (viscosity) rises with depth as the water cools, so it reads the same way.
177
+ _REVERSE_CBAR: frozenset[str] = frozenset({"P_dP", "SP", "sigma0", "nu"})
178
+
179
+
180
+ def _profile_window(stime: np.ndarray, sec: Section) -> np.ndarray:
181
+ """Boolean mask of profiles whose start time falls in the section window.
182
+
183
+ *stime* is per-profile epoch seconds (these combos have no ``time``
184
+ dimension, so we filter the ``profile`` axis rather than ``isel(time=...)``).
185
+ """
186
+ stime = np.asarray(stime, dtype=float)
187
+ mask = np.ones(stime.shape, dtype=bool)
188
+ if sec.start is not None:
189
+ mask &= stime >= float(xaxis.to_epoch_seconds(np.array([sec.start]))[0])
190
+ if sec.stop is not None:
191
+ mask &= stime <= float(xaxis.to_epoch_seconds(np.array([sec.stop]))[0])
192
+ return mask
193
+
194
+
195
+ def _make_norm(name: str, z: np.ndarray, args: argparse.Namespace, clim: dict):
196
+ """Pick a matplotlib norm for variable *name* on data *z*.
197
+
198
+ log for dissipation/diffusivity/N2; symmetric-linear for diverging fields;
199
+ linear otherwise. Per-variable ``--clim`` wins, then global --vmin/--vmax
200
+ (single-var only). Returns ``None`` when a log field has no positive data.
201
+ """
202
+ from matplotlib.colors import LogNorm, Normalize, SymLogNorm
203
+
204
+ lo, hi = clim.get(name, (args.vmin, args.vmax))
205
+ explicit = name in clim or args.vmin is not None or args.vmax is not None
206
+
207
+ # Reversed --clim (MIN >= MAX) would silently invert the colorbar on the
208
+ # linear/diverging paths; validate centrally so every branch errors clearly.
209
+ if lo is not None and hi is not None and lo >= hi:
210
+ raise SystemExit(
211
+ f"color-scale minimum must be < maximum for {name!r} (got {lo}, {hi})"
212
+ )
213
+
214
+ if name in _SYMLOG_VARS:
215
+ # N2 is log-scaled but can go slightly negative (overturning/unstable
216
+ # patches); SymLog keeps the dynamic range while showing negatives
217
+ # distinctly from no-data, where LogNorm would mask them.
218
+ finite = z[np.isfinite(z)]
219
+ if finite.size == 0:
220
+ return None
221
+ amax = float(np.nanmax(np.abs(finite)))
222
+ if amax <= 0:
223
+ return None
224
+ pos = finite[finite > 0]
225
+ linthresh = float(np.quantile(pos, 0.05)) if pos.size else amax * 1.0e-3
226
+ vmin = lo if lo is not None else float(np.nanmin(finite))
227
+ vmax = hi if hi is not None else amax
228
+ return SymLogNorm(linthresh=max(linthresh, amax * 1.0e-9),
229
+ vmin=vmin, vmax=vmax)
230
+
231
+ if name in _DIVERGING:
232
+ vmin, vmax = grid.linear_limits(z, lo, hi)
233
+ # Center on zero only when the robust (1/99%) range actually straddles
234
+ # it. One-signed data -- an all-stable dTdz (~ -0.17..0 K/m) or an
235
+ # offset Incl_Y (~76..90 deg) -- would otherwise mirror an unused sign
236
+ # and waste half the colorbar, making the range look far too wide.
237
+ if (not explicit and vmin is not None and vmax is not None
238
+ and vmin < 0.0 < vmax):
239
+ m = max(abs(vmin), abs(vmax))
240
+ return Normalize(vmin=-m, vmax=m)
241
+ return Normalize(vmin=vmin, vmax=vmax)
242
+
243
+ if name in _LOG_VARS or diagnostics.is_pseudo_var(name): # variances are log
244
+ if lo is not None and lo <= 0:
245
+ raise SystemExit(
246
+ f"color-scale minimum for log variable {name!r} must be > 0 (got {lo})"
247
+ )
248
+ vmin, vmax = layout.quantile_limits(z, lo, hi) # filters to > 0
249
+ if vmin is None or vmax is None or vmax <= 0:
250
+ return None
251
+ vmin = max(vmin, vmax / 1.0e6)
252
+ if vmin >= vmax: # all-equal data: show one decade so LogNorm is valid
253
+ vmin = vmax / 10.0
254
+ return LogNorm(vmin=vmin, vmax=vmax)
255
+
256
+ vmin, vmax = grid.linear_limits(z, lo, hi)
257
+ return Normalize(vmin=vmin, vmax=vmax)
258
+
259
+
260
+ def _union_qc_mask(
261
+ dss: xr.Dataset, qc_vars: tuple[str, ...], col: np.ndarray
262
+ ) -> np.ndarray | None:
263
+ """Boolean (bin, selected-profile) mask: True where ANY qc_drop_* flag is set.
264
+
265
+ Products may carry more than one flag field (mixing pulls both
266
+ ``qc_drop_epsilon`` and ``qc_drop_chi``); a cell is dropped if either marks
267
+ it (union). Absent flag fields are skipped; ``None`` means no QC to apply.
268
+ """
269
+ mask: np.ndarray | None = None
270
+ for qv in qc_vars:
271
+ if qv not in dss.data_vars:
272
+ continue
273
+ flag = np.asarray(dss[qv].transpose("bin", "profile").values)[:, col]
274
+ marked = np.isfinite(flag) & (flag > 0)
275
+ mask = marked if mask is None else (mask | marked)
276
+ return mask
277
+
278
+
279
+ def _merge_source_dirs(
280
+ ds: xr.Dataset, product: _Product, args: argparse.Namespace
281
+ ) -> xr.Dataset:
282
+ """Merge variables from ``product.merge_dirs`` onto the primary dataset.
283
+
284
+ Mixing draws from both the diss and chi combos, which share an identical
285
+ (bin, profile) grid from the same pipeline run. Extra variables (and their
286
+ qc_drop_* flags) are copied onto the primary dataset using its own
287
+ coordinates; shared variables keep the primary's copy. A missing extra dir
288
+ is skipped -- its variables simply drop out of the default grid -- but a
289
+ grid that does not line up is a hard error rather than a silent mis-join.
290
+ """
291
+ for prefix in product.merge_dirs:
292
+ # optional=True: a supplemental combo that simply was not produced (e.g.
293
+ # a chi-only run with no diss) degrades to None and is skipped in BOTH
294
+ # --root and --config modes -- matching the diagnostics / eps-chi idiom.
295
+ # (A genuine config *conflict* still raises, so data is never dropped
296
+ # silently over an ambiguous stage.)
297
+ src = resolve.resolve_for_args(args, prefix, optional=True)
298
+ if src is None:
299
+ continue
300
+ path = os.path.join(src, "combo.nc")
301
+ if not os.path.exists(path):
302
+ continue
303
+ with xr.open_dataset(path, decode_times=False) as extra:
304
+ # The assignment below is POSITIONAL (it bypasses xarray coordinate
305
+ # alignment), so the two combos must share the same grid by *value*,
306
+ # not merely the same counts -- otherwise extra data would be placed
307
+ # on the primary's axes and silently mis-joined.
308
+ if (extra.sizes.get("bin") != ds.sizes.get("bin")
309
+ or extra.sizes.get("profile") != ds.sizes.get("profile")):
310
+ raise SystemExit(
311
+ f"{prefix} grid {dict(extra.sizes)} != {product.dir_prefix} "
312
+ f"{dict(ds.sizes)}; cannot merge mixing sources")
313
+ if "bin" in ds.coords and "bin" in extra.coords and not np.allclose(
314
+ np.asarray(ds["bin"].values, dtype=float),
315
+ np.asarray(extra["bin"].values, dtype=float),
316
+ rtol=0.0, atol=1e-6):
317
+ raise SystemExit(
318
+ f"{prefix} depth bins differ from {product.dir_prefix}; "
319
+ "cannot merge mixing sources")
320
+ # stime is epoch seconds (~1.7e9), where np.allclose's default
321
+ # rtol=1e-5 spans ~4.8 h -- far too loose to detect different casts.
322
+ # Compare with a 1 s absolute tolerance (and no relative term).
323
+ if "stime" in ds and "stime" in extra and not np.allclose(
324
+ np.asarray(ds["stime"].values, dtype=float),
325
+ np.asarray(extra["stime"].values, dtype=float),
326
+ rtol=0.0, atol=1.0, equal_nan=True):
327
+ raise SystemExit(
328
+ f"{prefix} profiles (stime) differ from {product.dir_prefix}; "
329
+ "the combos are not the same casts")
330
+ for name, da in extra.data_vars.items():
331
+ if name in ds.data_vars or not set(da.dims) <= set(ds.dims):
332
+ continue
333
+ ds[name] = (da.dims, da.load().values, dict(da.attrs))
334
+ return ds
335
+
336
+
337
+ def _build_profiles_figure(
338
+ ds: xr.Dataset,
339
+ sec: Section,
340
+ variables: list[str],
341
+ args: argparse.Namespace,
342
+ clim: dict[str, tuple[float, float]],
343
+ product: _Product,
344
+ ) -> Figure | None:
345
+ """Render one section of the (bin, profile) product to a Figure."""
346
+ import cmocean
347
+ from matplotlib.ticker import FuncFormatter
348
+
349
+ dss = ds.isel(profile=np.flatnonzero(_profile_window(ds["stime"].values, sec)))
350
+ if dss.sizes.get("profile", 0) == 0:
351
+ print(f"section {sec.name!r}: no profiles in time window; skipped")
352
+ return None
353
+
354
+ def _available(v: str) -> bool:
355
+ if diagnostics.is_pseudo_var(v):
356
+ return True
357
+ if v not in dss.data_vars:
358
+ return False
359
+ # A plottable panel var needs both depth (bin) and profile dims; a 1-D
360
+ # (profile-only) var would crash the transpose("bin","profile") below,
361
+ # so treat it as missing -> clear "not on product" message.
362
+ return {"bin", "profile"} <= set(dss[v].dims)
363
+
364
+ panel_vars = [v for v in variables if _available(v)]
365
+ missing = [v for v in variables if not _available(v)]
366
+ if not panel_vars:
367
+ print(f"section {sec.name!r}: none of {variables} present on product; skipped")
368
+ return None
369
+ if missing:
370
+ print(f"section {sec.name!r}: variables not on product, skipped: {missing}")
371
+
372
+ # Per-profile x; drop non-finite-x profiles and order columns by x.
373
+ lat, lon = dss["lat"].values, dss["lon"].values
374
+ xa = xaxis.compute(sec.method, lat, lon, dss["stime"].values, sec.params)
375
+ x = np.asarray(xa.x, dtype=float)
376
+ finite = np.flatnonzero(np.isfinite(x))
377
+ if finite.size == 0:
378
+ print(f"section {sec.name!r}: no finite x positions; skipped")
379
+ return None
380
+ col = finite[np.argsort(x[finite])] # finite profiles, sorted by x
381
+ xs = x[col]
382
+ depth = np.asarray(dss["bin"].values, dtype=float) # depth, m
383
+
384
+ # Diagnostic pseudo-variables (shear/vibration/T_dT variance) are computed
385
+ # at plot time from the raw per-profile files, matched by stime.
386
+ pseudo_grids: dict[str, np.ndarray] = {}
387
+ pseudo_in_panel = [v for v in panel_vars if diagnostics.is_pseudo_var(v)]
388
+ if pseudo_in_panel:
389
+ pdir = resolve.resolve_for_args(args, "profiles", optional=True)
390
+ if pdir is None:
391
+ print(f"section {sec.name!r}: no profiles_NN dir; skipping diagnostics "
392
+ f"{pseudo_in_panel}")
393
+ panel_vars = [v for v in panel_vars if v not in pseudo_in_panel]
394
+ if not panel_vars:
395
+ return None
396
+ else:
397
+ for pv in pseudo_in_panel:
398
+ pseudo_grids[pv] = diagnostics.compute_pseudo_grid(
399
+ pv, dss["stime"].values, depth, pdir,
400
+ hp_cut=args.hp_cut, despike_thresh=args.despike_thresh,
401
+ despike_smooth=args.despike_smooth, tol=args.stime_tol,
402
+ )
403
+
404
+ qc = _union_qc_mask(dss, product.qc_vars, col) if args.apply_qc else None
405
+ if qc is not None and qc.any():
406
+ print(f"section {sec.name!r}: QC flags NaN {int(qc.sum())} cells per panel")
407
+
408
+ # Panels in `ncols` columns, filled left-to-right, top-to-bottom. When
409
+ # --ncols is not given, fall back to the product's default (profiles = 3;
410
+ # a vertical stack elsewhere). Sections remain one figure each.
411
+ ncols = getattr(args, "ncols", None)
412
+ if ncols is None:
413
+ ncols = product.default_ncols
414
+ fig, axes, left_axes, col_bottom = layout.panel_grid(
415
+ len(panel_vars), ncols,
416
+ figsize=getattr(args, "figsize", None),
417
+ )
418
+ left_set = set(left_axes)
419
+ # Depth rows (bins) that carry finite data in at least one plotted panel;
420
+ # used to fit the shared depth axis to where there is valid data.
421
+ valid_rows = np.zeros(depth.shape, dtype=bool)
422
+
423
+ for ax, name in zip(axes, panel_vars):
424
+ is_pseudo = diagnostics.is_pseudo_var(name)
425
+ if is_pseudo:
426
+ z = pseudo_grids[name][:, col] # already (bin, profile)
427
+ cmap_name, label = diagnostics.pseudo_cmap(name), diagnostics.pseudo_label(name)
428
+ else:
429
+ z = np.asarray(dss[name].transpose("bin", "profile").values, dtype=float)[:, col]
430
+ # Apply the QC mask to the data panels, but never to a flag field
431
+ # itself -- plotting qc_drop_* is how you *see* which cells are
432
+ # flagged, so self-masking it would blank exactly what you asked for.
433
+ if qc is not None and name not in product.qc_vars:
434
+ z = np.where(qc, np.nan, z)
435
+ cmap_name = _CMAP.get(name, "thermal")
436
+ label = _CBAR_LABEL.get(name, var_label(ds, name))
437
+ # Flag in-situ-calibrated channels (FP07 T1/T2) from the variable's
438
+ # own provenance attr -- reflects what was actually applied, not the
439
+ # config intent (calibrate=true can fall back to factory).
440
+ cal = ds[name].attrs.get("calibration", "") if name in ds else ""
441
+ if isinstance(cal, str) and cal.startswith("in-situ"):
442
+ label = f"{label} (in-situ calib)"
443
+ norm = _make_norm(name, z, args, clim)
444
+ if norm is None or not np.any(np.isfinite(z)):
445
+ ax.text(0.5, 0.5, f"no valid {name}", transform=ax.transAxes,
446
+ ha="center", va="center")
447
+ if ax in left_set: # only the left column carries the shared y label
448
+ ax.set_ylabel("Depth (m)")
449
+ continue
450
+ valid_rows |= np.any(np.isfinite(z), axis=1)
451
+ cmap = getattr(cmocean.cm, cmap_name).copy()
452
+ cmap.set_bad(color="0.85") # unsampled depths: light gray
453
+ layout.plot_columns(ax, fig, xs, depth, z, cmap, norm, label,
454
+ gap_factor=args.gap_factor,
455
+ reverse_cbar=name in _REVERSE_CBAR)
456
+ if ax in left_set:
457
+ ax.set_ylabel("Depth (m)")
458
+
459
+ for ax in axes:
460
+ # Grid over the color mesh (axisbelow False) so it reads on any colormap.
461
+ ax.set_axisbelow(False)
462
+ ax.grid(True, color="0.4", linewidth=0.4, alpha=0.5)
463
+
464
+ axes[0].invert_yaxis() # 0 m at top (shared across panels)
465
+ if args.p_max is not None:
466
+ # Explicit depth clip wins over the data-driven fit.
467
+ axes[0].set_ylim(float(args.p_max), 0.0)
468
+ elif valid_rows.any():
469
+ # Fit the depth axis to where there is valid data rather than spanning
470
+ # the whole combo's bin grid (which pads the section with empty gray
471
+ # below its deepest sample). Pad half a bin so edge cells aren't clipped.
472
+ dv = depth[valid_rows]
473
+ pad = 0.5 * float(np.median(np.diff(depth))) if depth.size > 1 else 0.0
474
+ axes[0].set_ylim(float(dv.max()) + pad, max(float(dv.min()) - pad, 0.0))
475
+
476
+ for ax in col_bottom:
477
+ ax.set_xlabel(xa.label)
478
+ if xa.kind == "time":
479
+ ax.xaxis.set_major_formatter(
480
+ FuncFormatter(
481
+ lambda val, _pos: np.datetime_as_string(
482
+ np.datetime64(int(val), "s"), unit="m"
483
+ ).replace("T", " ")
484
+ )
485
+ )
486
+ for lbl in ax.get_xticklabels():
487
+ lbl.set_rotation(30)
488
+ lbl.set_horizontalalignment("right")
489
+
490
+ title_id = ds.attrs.get("id") or os.path.basename(os.path.normpath(args.root))
491
+ # Report the count actually drawn (finite-x casts, == len(col)), not the
492
+ # full profile dim — a non-finite spatial x drops that cast from the plot,
493
+ # so dss.sizes['profile'] over-counts. Show "N of M" when they differ.
494
+ n_drawn = len(col)
495
+ n_total = int(dss.sizes["profile"])
496
+ casts = grouped(n_drawn) if n_drawn == n_total else f"{grouped(n_drawn)} of {grouped(n_total)}"
497
+ fig.suptitle(getattr(args, "title", None) or (
498
+ f"{title_id} — {product.label}: {sec.name} — "
499
+ f"x-axis: {sec.method} — {casts} casts"
500
+ ))
501
+ layout.fit_colorbar_labels(fig) # long var labels overflow short per-panel bars
502
+ return fig
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # CLI plumbing
507
+ # ---------------------------------------------------------------------------
508
+
509
+
510
+ def add_arguments(p: argparse.ArgumentParser) -> None:
511
+ """Register the shared (bin, profile) section flags on *p*.
512
+
513
+ The product itself is fixed per subcommand (``profiles`` / ``epsilon`` /
514
+ ``chi`` / ``mixing``) by the :class:`ProductView` that owns *p*, so there is
515
+ no ``--product`` flag; the engine reads ``args.product`` set by the view.
516
+ """
517
+ add_section_arguments(p)
518
+ p.add_argument("--p-max", type=float, default=None,
519
+ help="clip the depth axis at this value [m]")
520
+ p.add_argument("--gap-factor", type=float, default=4.0,
521
+ help="split casts into clusters when the x-gap exceeds this "
522
+ "multiple of the median cast spacing (default 4)")
523
+ p.add_argument("--apply-qc", dest="apply_qc", action="store_true", default=True,
524
+ help="NaN cells flagged by the product's qc_drop_* field (default)")
525
+ p.add_argument("--no-qc", dest="apply_qc", action="store_false",
526
+ help="ignore qc_drop_* and plot raw values")
527
+ # Diagnostic pseudo-variables (--var sh1_var / Ax_var / T1_dT1_var ...):
528
+ # binned variance of a raw fast channel, high-pass filtered + despiked (the
529
+ # epsilon path's two steps, applied over the whole cast), matched to combo
530
+ # casts by stime. Computed at plot time.
531
+ p.add_argument("--hp-cut", type=float, default=1.0,
532
+ help="high-pass cutoff [Hz] for diagnostic variance channels (default 1)")
533
+ p.add_argument("--despike-thresh", type=float, default=8.0,
534
+ help="despike threshold for diagnostic variance channels (default 8)")
535
+ p.add_argument("--despike-smooth", type=float, default=0.5,
536
+ help="despike envelope cutoff [Hz] for diagnostics (default 0.5)")
537
+ p.add_argument("--stime-tol", type=float, default=1.0,
538
+ help="max stime mismatch [s] when matching casts to raw files (default 1)")
539
+
540
+
541
+ def build_figures(args: argparse.Namespace) -> Iterator[tuple[str, Any]]:
542
+ """Yield one ``(stem, Figure)`` per resolved section (no saving/showing).
543
+
544
+ A **generator**, lazily, so a streaming caller (``run``'s save path, the
545
+ ``figure`` PDF driver) can save and ``close`` each figure before the next is
546
+ built — bounded to one open figure at a time. Sections with no finite data
547
+ are skipped, so fewer figures may be yielded than there are sections.
548
+ """
549
+ with contextlib.suppress(locale.Error):
550
+ locale.setlocale(locale.LC_NUMERIC, "")
551
+
552
+ args.root = resolve.require_root(args) # backfill from --config if needed
553
+ if args.gap_factor <= 0:
554
+ raise SystemExit("--gap-factor must be > 0")
555
+
556
+ product = PRODUCTS[args.product]
557
+ src = resolve.resolve_for_args(args, product.dir_prefix)
558
+ if src is None:
559
+ raise SystemExit(f"No {product.dir_prefix} dir under {args.root}")
560
+ path = os.path.join(src, "combo.nc")
561
+ if not os.path.exists(path):
562
+ raise SystemExit(f"{product.label} combo not found: {path}")
563
+
564
+ # decode_times=False keeps stime / bin numeric (epoch seconds / meters).
565
+ ds = xr.open_dataset(path, decode_times=False)
566
+ try:
567
+ # Validate the primary grid first, so a malformed combo reports the
568
+ # clear "expected a (bin, profile) product" rather than a merge-size
569
+ # mismatch against a None dim.
570
+ if "profile" not in ds.dims or "bin" not in ds.dims:
571
+ raise SystemExit(f"{path}: expected a (bin, profile) product")
572
+ ds = _merge_source_dirs(ds, product, args) # pull in e.g. diss vars for mixing
573
+ sections = resolve_sections(args)
574
+ if args.var:
575
+ variables = list(args.var)
576
+ else:
577
+ variables = [v for v in product.default_vars if v in ds.data_vars]
578
+ if not variables:
579
+ raise SystemExit(
580
+ f"{product.label}: none of the default variables "
581
+ f"{list(product.default_vars)} are present; available: "
582
+ f"{sorted(str(v) for v in ds.data_vars)}; pass --var"
583
+ )
584
+ clim = parse_clim(args.clim)
585
+ single_var_limit_guard(args, variables)
586
+ for sec in sections:
587
+ with close_new_figs_on_error(): # close a half-built figure if it raises
588
+ fig = _build_profiles_figure(ds, sec, variables, args, clim, product)
589
+ if fig is not None:
590
+ yield f"{product.label}_{safe_name(sec.name)}", fig
591
+ finally:
592
+ ds.close() # figures hold their own arrays, so the dataset can close now
593
+
594
+
595
+ def run(args: argparse.Namespace) -> str:
596
+ """Render every section of the selected product; show or write PNGs."""
597
+ args.root = resolve.require_root(args) # so out_dir/display are known up front
598
+ display = args.out_dir is None and can_display()
599
+ # closing_figs releases the generator's dataset handle even if a save raises
600
+ # mid-stream (not left to GC).
601
+ if display:
602
+ with closing_figs(build_figures(args)) as figs:
603
+ shown = save_or_show(figs, None, fig_dpi(args))
604
+ return f"displayed {shown} section(s)"
605
+
606
+ out_dir = args.out_dir or args.root
607
+ os.makedirs(out_dir, exist_ok=True)
608
+ with closing_figs(build_figures(args)) as figs:
609
+ save_or_show(figs, out_dir, fig_dpi(args))
610
+ return str(out_dir)
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # Per-product views
615
+ # ---------------------------------------------------------------------------
616
+ #
617
+ # One engine, one subcommand per product. Each view binds ``args.product`` and
618
+ # exposes the same ``add_arguments`` / ``build_figures`` / ``run`` surface the
619
+ # CLI dispatcher (:mod:`cli`) and the figure driver (:mod:`figure`) consume from
620
+ # a plain plot module — so ``perturb-plot epsilon`` and ``preset: epsilon`` reuse
621
+ # this file without a ``--product`` flag.
622
+
623
+
624
+ @dataclass(frozen=True)
625
+ class ProductView:
626
+ """The profiles engine bound to one product (``diss`` / ``chi`` / ...).
627
+
628
+ Duck-types a plot module: ``add_arguments`` registers the shared section
629
+ flags (no ``--product``), and ``build_figures`` / ``run`` set
630
+ ``args.product`` before delegating to the module-level functions.
631
+ """
632
+
633
+ product: str
634
+
635
+ def _bind(self, args: argparse.Namespace) -> argparse.Namespace:
636
+ args.product = self.product
637
+ return args
638
+
639
+ def add_arguments(self, p: argparse.ArgumentParser) -> None:
640
+ add_arguments(p)
641
+
642
+ def build_figures(self, args: argparse.Namespace) -> Iterator[tuple[str, Any]]:
643
+ return build_figures(self._bind(args))
644
+
645
+ def run(self, args: argparse.Namespace) -> str:
646
+ return run(self._bind(args))
647
+
648
+
649
+ PROFILES = ProductView("profiles")
650
+ EPSILON = ProductView("diss")
651
+ CHI = ProductView("chi")
652
+ MIXING = ProductView("mixing")