microstructure-tpw 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
|
@@ -0,0 +1,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)
|