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,391 @@
1
+ # Jun-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """``perturb-plot scalar`` — depth-vs-x scalar sections from the CTD combo.
3
+
4
+ Reads the CTD trajectory product ``ctd_combo_NN/combo.nc`` (CF
5
+ featureType=trajectory, a continuous down/up sawtooth on a ``time`` axis) and
6
+ renders, for each *section*, depth (y, inverted) against a chosen x-axis with a
7
+ scalar field in color.
8
+
9
+ A **section** is purely a way of chopping the trajectory and choosing the
10
+ x-axis: a name, an optional UTC ``start``/``stop`` window, and an ``xaxis``
11
+ method with its parameters. Sections come from a YAML file (``--sections``) or
12
+ from ad-hoc CLI flags. *Rendering* choices (which variables, depth/x bin
13
+ sizes, color limits) are separate CLI options that apply to every section in
14
+ the run.
15
+
16
+ By default the figures are shown on screen. They are written to PNG files
17
+ instead when ``--out-dir`` is given, or when there is no interactive display
18
+ available (no controlling tty, or a non-GUI matplotlib backend such as Agg).
19
+
20
+ The trajectory is gridded by binning samples onto a regular ``(x, depth)``
21
+ mesh and averaging each cell (:func:`grid.grid_mean`); empty cells stay NaN —
22
+ no interpolation across gaps.
23
+
24
+ The section/x-axis/CLI machinery lives in :mod:`odas_tpw.perturb.plot.sections`
25
+ and is shared with the ``profiles`` subcommand.
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 typing import TYPE_CHECKING, Any
36
+
37
+ import numpy as np
38
+ import xarray as xr
39
+
40
+ from odas_tpw.perturb import resolve
41
+ from odas_tpw.perturb.plot import grid, layout, xaxis
42
+ from odas_tpw.perturb.plot.sections import (
43
+ Section,
44
+ add_section_arguments,
45
+ close_new_figs_on_error,
46
+ closing_figs,
47
+ fig_dpi,
48
+ load_sections,
49
+ resolve_sections,
50
+ save_or_show,
51
+ single_var_limit_guard,
52
+ )
53
+ from odas_tpw.perturb.plot.sections import (
54
+ can_display as _can_display,
55
+ )
56
+ from odas_tpw.perturb.plot.sections import (
57
+ grouped as _grouped,
58
+ )
59
+ from odas_tpw.perturb.plot.sections import (
60
+ override_xaxis as _override_xaxis,
61
+ )
62
+ from odas_tpw.perturb.plot.sections import (
63
+ parse_clim as _parse_clim,
64
+ )
65
+ from odas_tpw.perturb.plot.sections import (
66
+ parse_time as _parse_time,
67
+ )
68
+ from odas_tpw.perturb.plot.sections import (
69
+ parse_waypoints as _parse_waypoints,
70
+ )
71
+ from odas_tpw.perturb.plot.sections import (
72
+ safe_name as _safe_name,
73
+ )
74
+ from odas_tpw.perturb.plot.sections import (
75
+ select_sections as _select_sections,
76
+ )
77
+ from odas_tpw.perturb.plot.sections import (
78
+ var_label as _var_label,
79
+ )
80
+
81
+ if TYPE_CHECKING:
82
+ from matplotlib.figure import Figure
83
+
84
+ # Helpers extracted to ``sections`` are re-exported here so existing tests that
85
+ # reference ``scalar.<helper>`` keep resolving.
86
+ __all__ = [
87
+ "Section",
88
+ "_default_variables",
89
+ "_override_xaxis",
90
+ "_parse_clim",
91
+ "_parse_time",
92
+ "_parse_waypoints",
93
+ "_select_sections",
94
+ "add_arguments",
95
+ "load_sections",
96
+ "run",
97
+ ]
98
+
99
+ # Default scalar panels when the user names none, plus optional sensor channels
100
+ # added automatically when the instrument carries them.
101
+ _DEFAULT_VARS: tuple[str, ...] = ("JAC_T", "SP", "sigma0", "rho")
102
+ _OPTIONAL_VARS: tuple[str, ...] = ("DO", "Chlorophyll", "Turbidity")
103
+ # Default column count when --ncols is not given (a 2-wide grid).
104
+ _DEFAULT_NCOLS = 2
105
+
106
+ # Per-variable cmocean colormap. Diverging fields center the color scale at 0.
107
+ _CMAP: dict[str, str] = {
108
+ "JAC_T": "thermal",
109
+ "CT": "thermal",
110
+ "T": "thermal",
111
+ "SP": "haline",
112
+ "SA": "haline",
113
+ "sigma0": "dense",
114
+ "rho": "dense",
115
+ "DO": "oxy",
116
+ "Chlorophyll": "algae",
117
+ "Turbidity": "turbid",
118
+ "N2": "amp",
119
+ "dTdz": "balance",
120
+ }
121
+ _DIVERGING: frozenset[str] = frozenset({"dTdz"})
122
+ # Variables whose colorbar runs min-at-top to max-at-bottom, mirroring the
123
+ # (inverted) depth axis -- salinity and density both increase with depth.
124
+ _CBAR_MIN_AT_TOP: frozenset[str] = frozenset({"SP", "sigma0"})
125
+
126
+ # Explicit colorbar labels that override the CF long_name/units default
127
+ # (var_label) for the CTD scalars. "JFE" names the JFE Advantech CTD; the
128
+ # density label uses mathtext so it renders as sigma-zero (sigma_0).
129
+ _CBAR_LABEL: dict[str, str] = {
130
+ "JAC_T": "in-situ T (°C) (JFE)",
131
+ "SP": "Salinity (PSU) (JFE)",
132
+ "sigma0": r"$\sigma_0$ (kg/m³) (JFE)",
133
+ }
134
+
135
+
136
+ def _default_variables(ds: xr.Dataset) -> list[str]:
137
+ """Default panel set: the standard scalars present, plus optional sensors."""
138
+ chosen = [v for v in _DEFAULT_VARS if v in ds.data_vars]
139
+ chosen += [v for v in _OPTIONAL_VARS if v in ds.data_vars]
140
+ return chosen
141
+
142
+
143
+ def _time_subset(ds: xr.Dataset, sec: Section) -> xr.Dataset:
144
+ """Index the trajectory to the section's [start, stop] UTC window."""
145
+ t = ds["time"].values
146
+ # dtype-agnostic: sec.start/stop are datetime64. If the combo's time was
147
+ # decoded to datetime64, compare directly; if it is numeric (epoch seconds,
148
+ # no CF units), compare in epoch seconds so we don't raise a cryptic
149
+ # UFuncTypeError mixing float64 with datetime64.
150
+ numeric = not np.issubdtype(t.dtype, np.datetime64)
151
+ if numeric:
152
+ t = t.astype(np.float64)
153
+
154
+ def _bound(b):
155
+ if b is None:
156
+ return None
157
+ return float(xaxis.to_epoch_seconds(np.array([b]))[0]) if numeric else b
158
+
159
+ mask = np.ones(t.shape, dtype=bool)
160
+ start, stop = _bound(sec.start), _bound(sec.stop)
161
+ if start is not None:
162
+ mask &= t >= start
163
+ if stop is not None:
164
+ mask &= t <= stop
165
+ return ds.isel(time=np.flatnonzero(mask))
166
+
167
+
168
+ def _build_section_figure(
169
+ ds: xr.Dataset,
170
+ sec: Section,
171
+ variables: list[str],
172
+ args: argparse.Namespace,
173
+ clim: dict[str, tuple[float, float]],
174
+ ) -> Figure | None:
175
+ """Render one section to a matplotlib Figure (not saved or shown here).
176
+
177
+ Returns ``None`` when the section has no plottable data (empty window, no
178
+ requested variable present, no finite x positions). The caller decides
179
+ whether to display or save the returned figure.
180
+ """
181
+ import cmocean
182
+ from matplotlib.colors import Normalize
183
+ from matplotlib.ticker import FuncFormatter
184
+
185
+ dss = _time_subset(ds, sec)
186
+ if dss.sizes.get("time", 0) == 0:
187
+ print(f"section {sec.name!r}: no samples in time window; skipped")
188
+ return None
189
+
190
+ lat = dss["lat"].values if "lat" in dss else np.full(dss.sizes["time"], np.nan)
191
+ lon = dss["lon"].values if "lon" in dss else np.full(dss.sizes["time"], np.nan)
192
+ depth = dss["depth"].values
193
+ xa = xaxis.compute(sec.method, lat, lon, dss["time"].values, sec.params)
194
+
195
+ panel_vars = [v for v in variables if v in dss.data_vars]
196
+ missing = [v for v in variables if v not in dss.data_vars]
197
+ if not panel_vars:
198
+ print(f"section {sec.name!r}: none of {variables} present on dataset; skipped")
199
+ return None
200
+ if missing:
201
+ print(f"section {sec.name!r}: variables not on dataset, skipped: {missing}")
202
+
203
+ # x grid: finite samples only; spatial axes can run either direction.
204
+ x = np.asarray(xa.x, dtype=float)
205
+ finite_x = x[np.isfinite(x)]
206
+ if finite_x.size == 0:
207
+ print(f"section {sec.name!r}: no finite x positions; skipped")
208
+ return None
209
+ x_lo, x_hi = float(finite_x.min()), float(finite_x.max())
210
+ x_edges = grid.make_edges(x_lo, x_hi, args.x_bin or grid.auto_step(x_lo, x_hi))
211
+
212
+ # depth grid: surface to the deepest sampled bin (or --depth-max).
213
+ finite_z = depth[np.isfinite(depth)]
214
+ z_hi = float(finite_z.max()) if finite_z.size else 1.0
215
+ if args.depth_max is not None:
216
+ z_hi = float(args.depth_max)
217
+ z_edges = grid.make_edges(0.0, z_hi, args.z_bin)
218
+
219
+ # Panels are arranged in `ncols` columns (default 1 = a vertical stack),
220
+ # filled left-to-right, top-to-bottom. Sections remain one figure each.
221
+ fig, axes, left_axes, col_bottom = layout.panel_grid(
222
+ len(panel_vars), getattr(args, "ncols", None) or _DEFAULT_NCOLS,
223
+ figsize=getattr(args, "figsize", None),
224
+ )
225
+ left_set = set(left_axes)
226
+
227
+ for ax, name in zip(axes, panel_vars):
228
+ # With sharey, only the left column carries the "Depth (m)" label (the
229
+ # other columns' y tick labels are hidden, so a bare label would mislead).
230
+ is_left = ax in left_set
231
+ v = dss[name].values
232
+ g, _count = grid.grid_mean(x, depth, v, x_edges, z_edges)
233
+ if not np.any(np.isfinite(g)):
234
+ ax.text(0.5, 0.5, f"no valid {name}", transform=ax.transAxes,
235
+ ha="center", va="center")
236
+ if is_left:
237
+ ax.set_ylabel("Depth (m)")
238
+ continue
239
+ # Per-variable --clim wins; else the global --vmin/--vmax (only set for
240
+ # a single-variable plot, enforced in run()); else auto 1/99 percentile.
241
+ lo_ov, hi_ov = clim.get(name, (args.vmin, args.vmax))
242
+ explicit_limits = name in clim or args.vmin is not None or args.vmax is not None
243
+ vmin, vmax = grid.linear_limits(g, lo_ov, hi_ov)
244
+ norm: Normalize
245
+ if (
246
+ name in _DIVERGING
247
+ and not explicit_limits
248
+ and vmin is not None
249
+ and vmax is not None
250
+ ):
251
+ # Symmetric about 0 so the diverging colormap's white midpoint
252
+ # always marks zero gradient -- even on a leg where dT/dz is
253
+ # entirely one sign. Explicit --vmin/--vmax override this.
254
+ m = max(abs(vmin), abs(vmax))
255
+ norm = Normalize(vmin=-m, vmax=m)
256
+ else:
257
+ norm = Normalize(vmin=vmin, vmax=vmax)
258
+ cmap = getattr(cmocean.cm, _CMAP.get(name, "thermal")).copy()
259
+ cmap.set_bad(color="0.85") # empty cells: light gray, visibly unsampled
260
+ pcm = ax.pcolormesh(x_edges, z_edges, np.ma.masked_invalid(g),
261
+ cmap=cmap, norm=norm, shading="flat")
262
+ cbar_fmt: str | None = None
263
+ if name == "SP" and vmin is not None and vmax is not None:
264
+ # Salinity sits in a narrow band; show 2 decimals, or 1 when the
265
+ # range is wide enough to read -- avoids false precision on ticks.
266
+ cbar_fmt = "%.1f" if (vmax - vmin) >= 1.0 else "%.2f"
267
+ cbar_label = _CBAR_LABEL.get(name, _var_label(ds, name))
268
+ cbar = fig.colorbar(pcm, ax=ax, label=cbar_label, format=cbar_fmt)
269
+ if name in _CBAR_MIN_AT_TOP:
270
+ cbar.ax.invert_yaxis() # min at top, max at bottom (mirrors depth)
271
+ if is_left:
272
+ ax.set_ylabel("Depth (m)")
273
+
274
+ for ax in axes:
275
+ # Draw the grid over the color mesh (axisbelow False), a thin muted
276
+ # line so it reads on both light (thermal/haline) and dark (dense) maps.
277
+ ax.set_axisbelow(False)
278
+ ax.grid(True, color="0.4", linewidth=0.4, alpha=0.5)
279
+
280
+ axes[0].invert_yaxis() # sharey: inverts every panel's depth axis
281
+ axes[0].set_xlim(x_edges[0], x_edges[-1])
282
+
283
+ # The x label/formatter goes on the bottom-most panel of each column
284
+ # (layout.panel_grid already re-enabled their tick labels).
285
+ for ax in col_bottom:
286
+ ax.set_xlabel(xa.label)
287
+ if xa.kind == "time":
288
+ ax.xaxis.set_major_formatter(
289
+ FuncFormatter(
290
+ lambda val, _pos: np.datetime_as_string(
291
+ np.datetime64(int(val), "s"), unit="m"
292
+ ).replace("T", " ")
293
+ )
294
+ )
295
+ for lbl in ax.get_xticklabels():
296
+ lbl.set_rotation(30)
297
+ lbl.set_horizontalalignment("right")
298
+
299
+ title_id = ds.attrs.get("id") or os.path.basename(os.path.normpath(args.root))
300
+ # Report the count actually placed on the grid (finite-x samples), not the
301
+ # full time dim — samples with a non-finite spatial x are dropped, so
302
+ # dss.sizes['time'] over-counts. Show "N of M" when they differ.
303
+ n_drawn = int(finite_x.size)
304
+ n_total = int(dss.sizes["time"])
305
+ samples = (
306
+ _grouped(n_drawn)
307
+ if n_drawn == n_total
308
+ else f"{_grouped(n_drawn)} of {_grouped(n_total)}"
309
+ )
310
+ fig.suptitle(getattr(args, "title", None) or (
311
+ f"{title_id} — section: {sec.name} — "
312
+ f"x-axis: {sec.method} — {samples} samples"
313
+ ))
314
+ layout.fit_colorbar_labels(fig) # long var labels overflow short per-panel bars
315
+ return fig
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # CLI plumbing (perturb-plot subcommand contract)
320
+ # ---------------------------------------------------------------------------
321
+
322
+
323
+ def add_arguments(p: argparse.ArgumentParser) -> None:
324
+ """Register CLI flags for the scalar subcommand on *p*."""
325
+ add_section_arguments(p)
326
+ p.add_argument("--ctd-combo", default=None,
327
+ help="explicit ctd combo dir or combo.nc (default: latest under --root)")
328
+ p.add_argument("--z-bin", type=float, default=1.0, help="depth bin width [m]")
329
+ p.add_argument("--x-bin", type=float, default=None,
330
+ help="x bin width in x-axis units (default: ~300 columns)")
331
+ p.add_argument("--depth-max", type=float, default=None,
332
+ help="clip the depth axis at this value [m]")
333
+
334
+
335
+ def build_figures(args: argparse.Namespace) -> Iterator[tuple[str, Any]]:
336
+ """Yield one ``(stem, Figure)`` per resolved section (no saving/showing).
337
+
338
+ A **generator**, lazily, so a streaming caller (``run``'s save path, the
339
+ ``figure`` PDF driver) can save and ``close`` each figure before the next is
340
+ built — bounded to one open figure at a time. Sections with no finite data
341
+ are skipped, so fewer figures may be yielded than there are sections.
342
+ """
343
+ # Honor the user's locale for number formatting in titles. Scoped to
344
+ # LC_NUMERIC so we don't disturb matplotlib's float parsing (LC_CTYPE) or
345
+ # collation; falls back silently when the environment locale is unset.
346
+ with contextlib.suppress(locale.Error):
347
+ locale.setlocale(locale.LC_NUMERIC, "")
348
+
349
+ args.root = resolve.require_root(args) # backfill from --config if needed
350
+
351
+ src = args.ctd_combo or resolve.resolve_for_args(args, "ctd_combo")
352
+ if src is None:
353
+ raise SystemExit(f"No ctd_combo dir under {args.root}")
354
+ path = src if src.endswith(".nc") else os.path.join(src, "combo.nc")
355
+ if not os.path.exists(path):
356
+ raise SystemExit(f"CTD combo not found: {path}")
357
+
358
+ ds = xr.open_dataset(path) # default CF decoding -> datetime64 time
359
+ try:
360
+ if "time" not in ds.dims:
361
+ raise SystemExit(f"{path}: expected a CTD trajectory with a 'time' dimension")
362
+ sections = resolve_sections(args)
363
+ variables = list(args.var) if args.var else _default_variables(ds)
364
+ clim = _parse_clim(args.clim)
365
+ single_var_limit_guard(args, variables)
366
+ for sec in sections:
367
+ with close_new_figs_on_error(): # close a half-built figure if it raises
368
+ fig = _build_section_figure(ds, sec, variables, args, clim)
369
+ if fig is not None:
370
+ yield f"scalar_{_safe_name(sec.name)}", fig
371
+ finally:
372
+ ds.close() # figures hold their own arrays, so the dataset can close now
373
+
374
+
375
+ def run(args: argparse.Namespace) -> str:
376
+ """Render every section; show on screen, or write PNGs and return their dir."""
377
+ args.root = resolve.require_root(args) # so out_dir/display are known up front
378
+ # Show on screen unless the user asked for files or no display is available.
379
+ display = args.out_dir is None and _can_display()
380
+ # closing_figs releases the generator's dataset handle even if a save raises
381
+ # mid-stream (not left to GC).
382
+ if display:
383
+ with closing_figs(build_figures(args)) as figs:
384
+ shown = save_or_show(figs, None, fig_dpi(args))
385
+ return f"displayed {shown} section(s)"
386
+
387
+ out_dir = args.out_dir or args.root
388
+ os.makedirs(out_dir, exist_ok=True)
389
+ with closing_figs(build_figures(args)) as figs:
390
+ save_or_show(figs, out_dir, fig_dpi(args))
391
+ return str(out_dir)