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,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]}
|