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,547 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """``perturb-plot overview`` — dissipation over a stratification/water-mass row.
3
+
4
+ One figure per *section*, three rows sharing one inverted depth axis and one
5
+ section x-axis:
6
+
7
+ * row 1 (full width): ``⟨ε⟩`` — TKE dissipation, from ``diss_combo``
8
+ * row 2 (full width): ``⟨χ⟩`` — thermal-variance dissipation, from ``chi_combo``
9
+ * row 3 (side by side): a named *context* set chosen by ``--bottom`` —
10
+ ``ctd`` (default) = ``dT/dz``, ``Θ`` (conservative temperature) and ``S``
11
+ (practical salinity) from ``combo``; ``mixing`` = ``K_rho``, ``K_T`` and
12
+ ``Gamma`` from ``chi_combo``. ``--var`` overrides the set with an explicit list.
13
+
14
+ The two dissipation rows and the default context row are all per-cast
15
+ ``(bin, profile)`` fields, drawn as depth-by-column meshes with the ``profiles``
16
+ engine's :func:`layout.plot_columns` (one mesh per x-cluster, blank gaps kept).
17
+
18
+ Each panel variable is resolved *independently* to the first product that
19
+ actually carries it (:data:`_VAR_STAGES`) and drawn from that product's own
20
+ grid — the three depth products (``combo`` / ``diss_combo`` / ``chi_combo``)
21
+ are binned separately and need not share a ``bin`` grid or profile set, so we
22
+ never merge them onto one array (unlike the ``mixing`` product). A scalar that
23
+ is only present as a continuous ``ctd_combo`` trajectory falls back to the
24
+ ``scalar`` gridding path (:func:`grid.grid_mean`). A partial run (no chi, no
25
+ CTD) still renders what it has and labels the rest "no valid <var>".
26
+
27
+ Section parsing, ``--select`` / ``--xaxis`` override, ``--clim`` and display
28
+ behaviour are shared with ``profiles`` / ``scalar`` via :mod:`sections`; panel
29
+ styling (colormap / colorbar label / norm / reversed-bar) is reused from
30
+ :mod:`profiles` so a given variable reads identically across subcommands.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import contextlib
37
+ import locale
38
+ import os
39
+ from collections.abc import Iterator
40
+ from typing import TYPE_CHECKING, Any, NamedTuple
41
+
42
+ import numpy as np
43
+ import xarray as xr
44
+
45
+ from odas_tpw.perturb import resolve
46
+ from odas_tpw.perturb.plot import grid, layout, xaxis
47
+ from odas_tpw.perturb.plot import profiles as _prof
48
+ from odas_tpw.perturb.plot.scalar import _time_subset
49
+ from odas_tpw.perturb.plot.sections import (
50
+ add_section_arguments,
51
+ can_display,
52
+ close_new_figs_on_error,
53
+ closing_figs,
54
+ fig_dpi,
55
+ grouped,
56
+ parse_clim,
57
+ resolve_sections,
58
+ safe_name,
59
+ save_or_show,
60
+ single_var_limit_guard,
61
+ var_label,
62
+ )
63
+
64
+ if TYPE_CHECKING:
65
+ from matplotlib.axes import Axes
66
+ from matplotlib.figure import Figure
67
+
68
+ # Fixed dissipation rows; the context row (row 3) is chosen by --bottom (a named
69
+ # set) or overridden outright by --var.
70
+ _DEFAULT_EPS = "epsilonMean"
71
+ _DEFAULT_CHI = "chiMean"
72
+ _BOTTOM_SETS: dict[str, tuple[str, ...]] = {
73
+ # dT/dz, conservative temperature and salinity — the CTD/water-mass context.
74
+ "ctd": ("dTdz", "CT", "SP"),
75
+ # Osborn/Osborn-Cox diffusivities and the mixing efficiency (from chi_combo).
76
+ "mixing": ("K_rho", "K_T", "Gamma"),
77
+ }
78
+ _DEFAULT_BOTTOM = "ctd"
79
+
80
+ # Per variable, the products to look in (first present wins) and how that
81
+ # product renders: ``col`` = a (bin, profile) depth-by-column mesh; ``traj`` =
82
+ # the ``ctd_combo`` continuous trajectory gridded to depth-vs-x. CT/SP/sigma0
83
+ # are depth-binned into ``combo`` (preferred, same column path as ε/χ) and also
84
+ # exist as a (time,) trajectory in ``ctd_combo`` (fallback). dT/dz and N2 live
85
+ # in all three depth products; the background ``combo`` copy pairs most
86
+ # naturally with the CTD context row.
87
+ _VAR_STAGES: dict[str, tuple[tuple[str, str], ...]] = {
88
+ "epsilonMean": (("diss_combo", "col"),),
89
+ "e_1": (("diss_combo", "col"),),
90
+ "e_2": (("diss_combo", "col"),),
91
+ "chiMean": (("chi_combo", "col"),),
92
+ "chi_1": (("chi_combo", "col"),),
93
+ "chi_2": (("chi_combo", "col"),),
94
+ "dTdz": (("combo", "col"), ("chi_combo", "col"), ("diss_combo", "col")),
95
+ "N2": (("combo", "col"), ("chi_combo", "col"), ("diss_combo", "col")),
96
+ "CT": (("combo", "col"), ("ctd_combo", "traj")),
97
+ "SP": (("combo", "col"), ("ctd_combo", "traj")),
98
+ "SA": (("combo", "col"), ("ctd_combo", "traj")),
99
+ "sigma0": (("combo", "col"), ("ctd_combo", "traj")),
100
+ "rho": (("combo", "col"), ("ctd_combo", "traj")),
101
+ "JAC_T": (("combo", "col"),),
102
+ "T1": (("combo", "col"),),
103
+ "T2": (("combo", "col"),),
104
+ "T_mean": (("diss_combo", "col"), ("chi_combo", "col")),
105
+ "K_T": (("chi_combo", "col"),),
106
+ "K_rho": (("chi_combo", "col"),),
107
+ "Gamma": (("chi_combo", "col"),),
108
+ }
109
+ # Unknown variables: search the depth products, then the CTD trajectory.
110
+ _DEFAULT_STAGES: tuple[tuple[str, str], ...] = (
111
+ ("combo", "col"), ("chi_combo", "col"), ("diss_combo", "col"),
112
+ ("ctd_combo", "traj"),
113
+ )
114
+
115
+ # A dissipation panel is masked by its product's own QC flag (same dataset).
116
+ # The mixing context vars (K_T / K_rho / Gamma) are deliberately absent: their
117
+ # correct flags span two products (K_T/Gamma <- qc_drop_chi, but K_rho is
118
+ # epsilon-derived <- qc_drop_epsilon, which lives in diss_combo, not the
119
+ # chi_combo they are read from), so a same-dataset mask cannot reproduce what
120
+ # `perturb-plot mixing` does. Under the default qc.drop_action="nan" the pipeline
121
+ # already NaNs these derived quantities at compute time, so the context row is
122
+ # blank in dropped cells regardless; only under drop_action="flag" would the
123
+ # overview show mixing cells the mixing plot hides (documented divergence).
124
+ _VAR_QC: dict[str, str] = {
125
+ "epsilonMean": "qc_drop_epsilon", "e_1": "qc_drop_epsilon", "e_2": "qc_drop_epsilon",
126
+ "chiMean": "qc_drop_chi", "chi_1": "qc_drop_chi", "chi_2": "qc_drop_chi",
127
+ }
128
+
129
+
130
+ class _Extent(NamedTuple):
131
+ """What a drawn panel contributes to the shared axes / title."""
132
+
133
+ z_lo: float
134
+ z_hi: float
135
+ x_lo: float
136
+ x_hi: float
137
+ xlabel: str
138
+ xkind: str
139
+ ncol: int # cast columns (col panels); 0 for a trajectory panel
140
+
141
+
142
+ class _Stages:
143
+ """Open each product's ``combo.nc`` at most once; close them all at the end.
144
+
145
+ ``optional=True, conflict_ok=True`` so a product that simply was not run
146
+ (or is ambiguous under ``--config``) degrades to ``None`` and its panels
147
+ read "no valid <var>", rather than failing the whole figure.
148
+ """
149
+
150
+ def __init__(self, args: argparse.Namespace) -> None:
151
+ self._args = args
152
+ self._cache: dict[str, xr.Dataset | None] = {}
153
+
154
+ def get(self, prefix: str) -> xr.Dataset | None:
155
+ if prefix not in self._cache:
156
+ src = resolve.resolve_for_args(
157
+ self._args, prefix, optional=True, conflict_ok=True
158
+ )
159
+ ds: xr.Dataset | None = None
160
+ if src is not None:
161
+ path = src if src.endswith(".nc") else os.path.join(src, "combo.nc")
162
+ if os.path.exists(path):
163
+ # ctd_combo is a CF trajectory (decode time -> datetime64);
164
+ # the binned products keep stime / bin numeric.
165
+ ds = xr.open_dataset(path, decode_times=(prefix == "ctd_combo"))
166
+ self._cache[prefix] = ds
167
+ return self._cache[prefix]
168
+
169
+ def any_id(self) -> str | None:
170
+ for ds in self._cache.values():
171
+ if ds is not None and ds.attrs.get("id"):
172
+ return str(ds.attrs["id"])
173
+ return None
174
+
175
+ def close(self) -> None:
176
+ for ds in self._cache.values():
177
+ if ds is not None:
178
+ ds.close()
179
+
180
+
181
+ def _resolve_var(var: str, stages: _Stages) -> tuple[str, xr.Dataset] | None:
182
+ """First (kind, dataset) whose product exists and carries *var*, else None."""
183
+ for prefix, kind in _VAR_STAGES.get(var, _DEFAULT_STAGES):
184
+ ds = stages.get(prefix)
185
+ if ds is not None and var in ds.data_vars:
186
+ return kind, ds
187
+ return None
188
+
189
+
190
+ def _style(ds: xr.Dataset, var: str):
191
+ """(cmap_name, cbar_label, reverse_cbar) for *var*, reusing the profiles maps."""
192
+ import cmocean
193
+
194
+ cmap = getattr(cmocean.cm, _prof._CMAP.get(var, "thermal")).copy()
195
+ cmap.set_bad(color="0.85") # unsampled cells: light gray
196
+ label = _prof._CBAR_LABEL.get(var, var_label(ds, var))
197
+ return cmap, label, var in _prof._REVERSE_CBAR
198
+
199
+
200
+ def _panel_positions(
201
+ ds: xr.Dataset, kind: str, sec,
202
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
203
+ """``(lat, lon, epoch-seconds time)`` for a panel's windowed points, or None.
204
+
205
+ Positions only (no field read), so one shared ``signed_distance`` frame can
206
+ be fit over every panel before any is drawn. Times are normalized to epoch
207
+ seconds up front so the numeric (``stime``) column path and the datetime64
208
+ (``time``) trajectory path concatenate cleanly.
209
+ """
210
+ if kind == "traj":
211
+ dss = _time_subset(ds, sec)
212
+ if dss.sizes.get("time", 0) == 0 or "depth" not in dss:
213
+ return None
214
+ n = dss.sizes["time"]
215
+ lat = dss["lat"].values if "lat" in dss else np.full(n, np.nan)
216
+ lon = dss["lon"].values if "lon" in dss else np.full(n, np.nan)
217
+ t = dss["time"].values
218
+ else:
219
+ win = _prof._profile_window(ds["stime"].values, sec)
220
+ dss = ds.isel(profile=np.flatnonzero(win))
221
+ if dss.sizes.get("profile", 0) == 0:
222
+ return None
223
+ lat, lon, t = dss["lat"].values, dss["lon"].values, dss["stime"].values
224
+ return (np.asarray(lat, dtype=float).ravel(),
225
+ np.asarray(lon, dtype=float).ravel(),
226
+ np.asarray(xaxis.to_epoch_seconds(t), dtype=float).ravel())
227
+
228
+
229
+ def _shared_frame(stages, sec, panel_vars):
230
+ """One ``signed_distance`` frame (fit + label) over ALL panels' positions.
231
+
232
+ Only ``signed_distance`` derives its origin and orientation from the data,
233
+ so panels resolved to different products — or the dense ``ctd_combo``
234
+ trajectory — would each fit a *different* frame and fail to line up on the
235
+ shared x-axis this figure imposes. Fitting one frame over the union of
236
+ positions and projecting every panel onto it (see :func:`_panel_xaxis`)
237
+ keeps the rows in a common coordinate system. Absolute methods
238
+ (time / latitude / longitude / distance_from_point / along_line) need no
239
+ shared frame → ``None`` (per-panel ``xaxis.compute`` already agrees).
240
+ """
241
+ if sec.method != "signed_distance":
242
+ return None
243
+ lats, lons, times = [], [], []
244
+ for var in panel_vars:
245
+ res = _resolve_var(var, stages)
246
+ if res is None:
247
+ continue
248
+ pos = _panel_positions(res[1], res[0], sec)
249
+ if pos is not None:
250
+ lats.append(pos[0])
251
+ lons.append(pos[1])
252
+ times.append(pos[2])
253
+ if not lats:
254
+ return None
255
+ units = sec.params.get("units", "km")
256
+ r = xaxis.signed_distance_axis(
257
+ np.concatenate(lats), np.concatenate(lons), np.concatenate(times), units)
258
+ if not (np.isfinite(r.mid_lat) and np.isfinite(r.bearing_deg)):
259
+ return None # single station / no spatial spread — per-panel fallback is fine
260
+ return r, xaxis.signed_axis_label(r, units)
261
+
262
+
263
+ def _panel_xaxis(sec, lat, lon, time, frame):
264
+ """XAxis for one panel: project onto the shared *frame* (signed_distance),
265
+ else the standard per-panel :func:`xaxis.compute`."""
266
+ if frame is not None:
267
+ r, label = frame
268
+ units = sec.params.get("units", "km")
269
+ x = xaxis.project_onto_signed_axis(
270
+ lat, lon, r.mid_lat, r.mid_lon, r.bearing_deg, units)
271
+ return xaxis.XAxis(x=np.asarray(x, dtype=float), label=label, kind="spatial")
272
+ return xaxis.compute(sec.method, lat, lon, time, sec.params)
273
+
274
+
275
+ def _draw_col_panel(
276
+ ax: Axes, fig: Figure, ds: xr.Dataset, sec, var: str,
277
+ args: argparse.Namespace, clim: dict, frame=None,
278
+ ) -> _Extent | None:
279
+ """Draw a (bin, profile) field as a depth-by-column mesh (profiles path)."""
280
+ win = _prof._profile_window(ds["stime"].values, sec)
281
+ dss = ds.isel(profile=np.flatnonzero(win))
282
+ if dss.sizes.get("profile", 0) == 0 or {"bin", "profile"} - set(dss[var].dims):
283
+ return None
284
+ lat, lon = dss["lat"].values, dss["lon"].values
285
+ xa = _panel_xaxis(sec, lat, lon, dss["stime"].values, frame)
286
+ x = np.asarray(xa.x, dtype=float)
287
+ finite = np.flatnonzero(np.isfinite(x))
288
+ if finite.size == 0:
289
+ return None
290
+ col = finite[np.argsort(x[finite])] # finite casts, ordered by x
291
+ xs = x[col]
292
+ depth = np.asarray(dss["bin"].values, dtype=float)
293
+ z = np.asarray(dss[var].transpose("bin", "profile").values, dtype=float)[:, col]
294
+
295
+ qc_var = _VAR_QC.get(var)
296
+ if args.apply_qc and qc_var and qc_var in dss.data_vars:
297
+ flag = np.asarray(dss[qc_var].transpose("bin", "profile").values)[:, col]
298
+ z = np.where(np.isfinite(flag) & (flag > 0), np.nan, z)
299
+
300
+ norm = _prof._make_norm(var, z, args, clim)
301
+ if norm is None or not np.any(np.isfinite(z)):
302
+ return None
303
+ cmap, label, reverse = _style(ds, var)
304
+ layout.plot_columns(ax, fig, xs, depth, z, cmap, norm, label,
305
+ gap_factor=args.gap_factor, reverse_cbar=reverse)
306
+
307
+ rows = depth[np.any(np.isfinite(z), axis=1)]
308
+ z_lo, z_hi = (float(rows.min()), float(rows.max())) if rows.size else (np.nan, np.nan)
309
+ return _Extent(z_lo, z_hi, float(xs.min()), float(xs.max()),
310
+ xa.label, xa.kind, int(xs.size))
311
+
312
+
313
+ def _draw_traj_panel(
314
+ ax: Axes, fig: Figure, ds: xr.Dataset, sec, var: str,
315
+ args: argparse.Namespace, clim: dict, frame=None,
316
+ ) -> _Extent | None:
317
+ """Grid a ``ctd_combo`` trajectory scalar onto depth-vs-x (scalar path)."""
318
+ dss = _time_subset(ds, sec)
319
+ if dss.sizes.get("time", 0) == 0 or "depth" not in dss:
320
+ return None # no window, or a trajectory without a depth axis -> "no valid"
321
+ n = dss.sizes["time"]
322
+ lat = dss["lat"].values if "lat" in dss else np.full(n, np.nan)
323
+ lon = dss["lon"].values if "lon" in dss else np.full(n, np.nan)
324
+ depth = dss["depth"].values
325
+ xa = _panel_xaxis(sec, lat, lon, dss["time"].values, frame)
326
+ x = np.asarray(xa.x, dtype=float)
327
+ finite_x = x[np.isfinite(x)]
328
+ if finite_x.size == 0:
329
+ return None
330
+ x_lo, x_hi = float(finite_x.min()), float(finite_x.max())
331
+ x_edges = grid.make_edges(x_lo, x_hi, args.x_bin or grid.auto_step(x_lo, x_hi))
332
+ finite_z = depth[np.isfinite(depth)]
333
+ z_hi = float(finite_z.max()) if finite_z.size else 1.0
334
+ if args.p_max is not None:
335
+ z_hi = float(args.p_max)
336
+ z_edges = grid.make_edges(0.0, z_hi, args.z_bin)
337
+ g, _count = grid.grid_mean(x, depth, dss[var].values, x_edges, z_edges)
338
+ if not np.any(np.isfinite(g)):
339
+ return None
340
+ norm = _prof._make_norm(var, g, args, clim)
341
+ if norm is None:
342
+ return None
343
+ cmap, label, reverse = _style(ds, var)
344
+ pcm = ax.pcolormesh(x_edges, z_edges, np.ma.masked_invalid(g),
345
+ cmap=cmap, norm=norm, shading="flat")
346
+ cbar = fig.colorbar(pcm, ax=ax, label=label)
347
+ if reverse:
348
+ cbar.ax.invert_yaxis()
349
+
350
+ z_cen = 0.5 * (z_edges[:-1] + z_edges[1:])
351
+ zv = z_cen[np.any(np.isfinite(g), axis=1)]
352
+ z_lo, z_hi2 = (float(zv.min()), float(zv.max())) if zv.size else (np.nan, np.nan)
353
+ return _Extent(z_lo, z_hi2, x_lo, x_hi, xa.label, xa.kind, 0)
354
+
355
+
356
+ def _time_fmt(val: float, _pos) -> str:
357
+ """UTC 'YYYY-MM-DD HH:MM' tick label for an epoch-seconds x-axis."""
358
+ return np.datetime_as_string(np.datetime64(int(val), "s"), unit="m").replace("T", " ")
359
+
360
+
361
+ def _no_data(ax: Axes, var: str) -> None:
362
+ ax.text(0.5, 0.5, f"no valid {var}", transform=ax.transAxes,
363
+ ha="center", va="center")
364
+
365
+
366
+ def _build_overview_figure(
367
+ stages: _Stages, sec, eps_var: str, chi_var: str, bottom: list[str],
368
+ args: argparse.Namespace, clim: dict,
369
+ ) -> Figure | None:
370
+ """Render one section's 3-row overview to a Figure (not saved/shown here)."""
371
+ import matplotlib.pyplot as plt
372
+ from matplotlib.ticker import FuncFormatter
373
+
374
+ ncols = max(1, len(bottom))
375
+ figsize = tuple(args.figsize) if args.figsize else (max(11.0, 4.5 * ncols), 11.0)
376
+ fig = plt.figure(figsize=figsize, constrained_layout=True)
377
+ # Two independently laid-out regions: the full-width epsilon/chi stack over
378
+ # the multi-panel context row. Subfigures keep each region's per-panel
379
+ # colorbars aligned *within* the region — a single spanning gridspec detaches
380
+ # the context-row colorbars from their panels (constrained_layout can't
381
+ # reconcile a 3-wide colorbar row under two full-width spanning bars).
382
+ # squeeze=False -> a (2, 1) array of SubFigures. (The default squeeze=True
383
+ # returns a 1-D array here, which unpacks fine at runtime but trips mypy: its
384
+ # overload for the squeezed form is typed as a scalar SubFigure — "not
385
+ # iterable" — so pin squeeze=False and index explicitly.)
386
+ subfigs = fig.subfigures(2, 1, squeeze=False, height_ratios=(2.0, 1.2))
387
+ top_sf, bot_sf = subfigs[0, 0], subfigs[1, 0]
388
+ ax_eps, ax_chi = top_sf.subplots(2, 1, sharex=True, sharey=True)
389
+ bottom_axes = list(
390
+ bot_sf.subplots(1, ncols, sharex=True, sharey=True, squeeze=False)[0]
391
+ )
392
+ axes = [ax_eps, ax_chi, *bottom_axes]
393
+ panel_vars = [eps_var, chi_var, *bottom]
394
+
395
+ # For signed_distance, fit ONE frame over every panel's positions so the rows
396
+ # share a coordinate system (see _shared_frame); other methods -> None.
397
+ frame = _shared_frame(stages, sec, panel_vars)
398
+
399
+ extents: list[_Extent] = []
400
+ for ax, var in zip(axes, panel_vars):
401
+ res = _resolve_var(var, stages)
402
+ if res is None:
403
+ _no_data(ax, var)
404
+ continue
405
+ kind, ds = res
406
+ draw = _draw_traj_panel if kind == "traj" else _draw_col_panel
407
+ ext = draw(ax, fig, ds, sec, var, args, clim, frame)
408
+ if ext is None:
409
+ _no_data(ax, var)
410
+ else:
411
+ extents.append(ext)
412
+
413
+ if not extents:
414
+ plt.close(fig)
415
+ print(f"section {sec.name!r}: no data on any panel; skipped")
416
+ return None
417
+
418
+ for ax in axes:
419
+ ax.set_axisbelow(False) # grid over the mesh so it reads on any colormap
420
+ ax.grid(True, color="0.4", linewidth=0.4, alpha=0.5)
421
+ ax_chi.tick_params(labelbottom=False) # x labels live only on the context row
422
+ for ax in (ax_eps, ax_chi, bottom_axes[0]):
423
+ ax.set_ylabel("Depth (m)") # left column of every row
424
+
425
+ # Shared inverted depth axis (0 m at top). The two subfigure groups share y
426
+ # only within themselves, so pin the range on one axis of each group;
427
+ # set_ylim(z, 0) inverts without a separate invert_yaxis() call.
428
+ if args.p_max is not None:
429
+ z_top = float(args.p_max)
430
+ else:
431
+ z_his = [e.z_hi for e in extents if np.isfinite(e.z_hi)]
432
+ z_top = max(z_his) * 1.02 if z_his else 1.0
433
+ x_los = [e.x_lo for e in extents if np.isfinite(e.x_lo)]
434
+ x_his = [e.x_hi for e in extents if np.isfinite(e.x_hi)]
435
+ for ax in (ax_eps, bottom_axes[0]): # one per shared-axis group
436
+ ax.set_ylim(z_top, 0.0)
437
+ if x_los and x_his:
438
+ ax.set_xlim(min(x_los), max(x_his))
439
+
440
+ xlabel, xkind = extents[0].xlabel, extents[0].xkind
441
+ if xkind == "time":
442
+ for ax in bottom_axes:
443
+ ax.xaxis.set_major_formatter(FuncFormatter(_time_fmt))
444
+ for lbl in ax.get_xticklabels():
445
+ lbl.set_rotation(30)
446
+ lbl.set_horizontalalignment("right")
447
+ # One shared x label centered under the context row: the three panels share
448
+ # the section x-axis, and its label (often long — e.g. a signed_distance
449
+ # origin + orientation) would overlap if repeated under each panel.
450
+ bot_sf.supxlabel(xlabel)
451
+
452
+ ncasts = max((e.ncol for e in extents), default=0)
453
+ title_id = stages.any_id() or os.path.basename(os.path.normpath(args.root))
454
+ cast_note = f" — {grouped(ncasts)} casts" if ncasts else ""
455
+ fig.suptitle(getattr(args, "title", None) or (
456
+ f"{title_id} — overview: {sec.name} — x-axis: {sec.method}{cast_note}"
457
+ ))
458
+ layout.fit_colorbar_labels(fig) # long var labels overflow short per-panel bars
459
+ return fig
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # CLI plumbing (perturb-plot subcommand contract)
464
+ # ---------------------------------------------------------------------------
465
+
466
+
467
+ def add_arguments(p: argparse.ArgumentParser) -> None:
468
+ """Register CLI flags for the overview subcommand on *p*.
469
+
470
+ ``--var`` (repeatable) overrides the bottom *context* row only; the two
471
+ dissipation rows come from ``--eps-var`` / ``--chi-var``.
472
+ """
473
+ add_section_arguments(p)
474
+ p.add_argument("--bottom", choices=sorted(_BOTTOM_SETS), default=_DEFAULT_BOTTOM,
475
+ help="named variable set for the context row: 'ctd' = "
476
+ "dT/dz, CT, SP (default); 'mixing' = K_rho, K_T, Gamma. "
477
+ "Overridden by --var.")
478
+ p.add_argument("--eps-var", default=_DEFAULT_EPS,
479
+ help=f"variable for the top row (default: {_DEFAULT_EPS})")
480
+ p.add_argument("--chi-var", default=_DEFAULT_CHI,
481
+ help=f"variable for the middle row (default: {_DEFAULT_CHI})")
482
+ p.add_argument("--p-max", type=float, default=None,
483
+ help="clip the depth axis at this value [m]")
484
+ p.add_argument("--gap-factor", type=float, default=4.0,
485
+ help="split casts into clusters when the x-gap exceeds this "
486
+ "multiple of the median cast spacing (default 4)")
487
+ p.add_argument("--z-bin", type=float, default=1.0,
488
+ help="depth bin width [m] for CTD-trajectory panels (default 1)")
489
+ p.add_argument("--x-bin", type=float, default=None,
490
+ help="x bin width for CTD-trajectory panels (default: ~300 columns)")
491
+ p.add_argument("--apply-qc", dest="apply_qc", action="store_true", default=True,
492
+ help="NaN cells flagged by a dissipation product's qc_drop_* "
493
+ "field (default)")
494
+ p.add_argument("--no-qc", dest="apply_qc", action="store_false",
495
+ help="ignore qc_drop_* and plot raw values")
496
+
497
+
498
+ def build_figures(args: argparse.Namespace) -> Iterator[tuple[str, Any]]:
499
+ """Yield one ``(stem, Figure)`` per resolved section (no saving/showing).
500
+
501
+ A **generator**, lazily, so a streaming caller (``run``'s save path, the
502
+ ``figure`` PDF driver) can save and ``close`` each figure before the next is
503
+ built. Sections with no data on any panel are skipped.
504
+ """
505
+ with contextlib.suppress(locale.Error):
506
+ locale.setlocale(locale.LC_NUMERIC, "")
507
+
508
+ args.root = resolve.require_root(args) # backfill from --config if needed
509
+ if args.gap_factor <= 0:
510
+ raise SystemExit("--gap-factor must be > 0")
511
+
512
+ bottom = list(args.var) if args.var else list(_BOTTOM_SETS[args.bottom])
513
+ if len(bottom) > 6:
514
+ raise SystemExit("overview: the context row takes at most 6 --var entries")
515
+ # Overview is inherently multi-panel, so global --vmin/--vmax is ambiguous;
516
+ # this raises the same clear message as the other subcommands (use --clim).
517
+ single_var_limit_guard(args, [args.eps_var, args.chi_var, *bottom])
518
+ clim = parse_clim(args.clim)
519
+
520
+ stages = _Stages(args)
521
+ try:
522
+ sections = resolve_sections(args)
523
+ for sec in sections:
524
+ with close_new_figs_on_error(): # close a half-built figure if it raises
525
+ fig = _build_overview_figure(
526
+ stages, sec, args.eps_var, args.chi_var, bottom, args, clim
527
+ )
528
+ if fig is not None:
529
+ yield f"overview_{safe_name(sec.name)}", fig
530
+ finally:
531
+ stages.close() # figures hold their own arrays, so datasets can close now
532
+
533
+
534
+ def run(args: argparse.Namespace) -> str:
535
+ """Render every section's overview; show on screen, or write PNGs."""
536
+ args.root = resolve.require_root(args) # so out_dir/display are known up front
537
+ display = args.out_dir is None and can_display()
538
+ if display:
539
+ with closing_figs(build_figures(args)) as figs:
540
+ shown = save_or_show(figs, None, fig_dpi(args))
541
+ return f"displayed {shown} section(s)"
542
+
543
+ out_dir = args.out_dir or args.root
544
+ os.makedirs(out_dir, exist_ok=True)
545
+ with closing_figs(build_figures(args)) as figs:
546
+ save_or_show(figs, out_dir, fig_dpi(args))
547
+ return str(out_dir)
@@ -0,0 +1,16 @@
1
+ # Example figure spec — epsilon / chi / chi-over-epsilon pcolor. Copy and edit.
2
+ # perturb-plot figure --spec this.yaml
3
+ # Note: eps-chi plots depth vs cast number and has no x-axis, so 'section'/
4
+ # 'vars'/'clim' are not used for it; per-quantity limits are eps_vmin/eps_vmax,
5
+ # chi_vmin/chi_vmax, gam_vmin/gam_vmax.
6
+ source:
7
+ config: perturb.yaml
8
+
9
+ output_dir: figs/
10
+
11
+ figures:
12
+ - name: overview
13
+ preset: eps-chi
14
+ gap_seconds: 600
15
+ # eps_vmin: 1.0e-10
16
+ # eps_vmax: 1.0e-6
@@ -0,0 +1,32 @@
1
+ # Example figure spec — binned (bin, profile) products. Copy and edit.
2
+ # perturb-plot figure --spec this.yaml
3
+ # One preset per product: profiles | epsilon | chi | mixing (no --product).
4
+ # Override the source config from the CLI to compare runs, e.g.:
5
+ # perturb-plot figure --spec this.yaml --config perturb.2.yaml --output-dir figs/run2
6
+ source:
7
+ config: perturb.yaml
8
+
9
+ output_dir: figs/
10
+
11
+ figures:
12
+ - name: profiles
13
+ preset: profiles
14
+ # 3x3 overview (temperatures / water-mass / kinematics + stratification).
15
+ vars: [JAC_T, T1, T2, SP, rho, sigma0, W_slow, dTdz, N2]
16
+ ncols: 3
17
+ - name: epsilon
18
+ preset: epsilon
19
+ # 3-column overview (kinematics / window means / dissipation + stratification).
20
+ vars: [speed, nu, T_mean, e_1, e_2, epsilonMean, N2, dTdz]
21
+ ncols: 3
22
+ - name: chi
23
+ preset: chi
24
+ # 3-column overview (kinematics / window means / chi / stratification / QC).
25
+ vars: [speed, nu, T_mean, chi_1, chi_2, chiMean, N2, dTdz, qc_drop_chi]
26
+ ncols: 3
27
+ - name: mixing
28
+ preset: mixing
29
+ # Pulls e_* from the diss combo and chi/K/Gamma from the chi combo, merged
30
+ # on the shared (bin, profile) grid. 3-column: dissipation / chi / mixing.
31
+ vars: [e_1, e_2, epsilonMean, chi_1, chi_2, chiMean, K_T, K_rho, Gamma]
32
+ ncols: 3
@@ -0,0 +1,21 @@
1
+ # Example figure spec — CTD scalar sections. Copy and edit.
2
+ # perturb-plot figure --spec this.yaml
3
+ source:
4
+ config: perturb.yaml # resolve directories from this perturb config
5
+ # output_root: ~/Desktop/VMP_results # optional: override where to look
6
+ # root: ~/Desktop/VMP_results # opt-out of config resolution
7
+
8
+ output_dir: figs/
9
+
10
+ # Optional. Without a sections block, each figure renders one ad-hoc section
11
+ # (time x-axis). With one, a figure's `section:` selects by name (or "*"/list).
12
+ # sections:
13
+ # file: sections.yaml
14
+
15
+ figures:
16
+ - name: ts
17
+ preset: scalar
18
+ vars: [JAC_T, SP, sigma0, rho] # repeated --var
19
+ ncols: 2 # 2-column grid
20
+ depth_max: 200
21
+ clim: {JAC_T: [18, 28]} # {VAR: [min, max]}