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