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,506 @@
|
|
|
1
|
+
# Jun-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Shared section/x-axis/CLI helpers for perturb-plot section-style subcommands.
|
|
3
|
+
|
|
4
|
+
A *section* chops a product and chooses an x-axis: a name, an optional UTC
|
|
5
|
+
``start``/``stop`` window, and an ``xaxis`` method with its parameters. This
|
|
6
|
+
module owns the section spec, the ``sections.yaml`` loader, the ``--select`` /
|
|
7
|
+
``--xaxis`` override / ``--clim`` logic, and a few pure display/format helpers,
|
|
8
|
+
so both ``scalar`` (CTD trajectory) and ``profiles`` (binned (bin, profile)
|
|
9
|
+
grids) reuse one implementation.
|
|
10
|
+
|
|
11
|
+
It deliberately holds only *parsing + x-axis dispatch + UI* — NOT product
|
|
12
|
+
reading, gridding, or the per-product default variable sets, which differ by
|
|
13
|
+
subcommand.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import contextlib
|
|
20
|
+
import locale
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Iterable, Iterator
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import xarray as xr
|
|
30
|
+
|
|
31
|
+
from odas_tpw.perturb.plot import xaxis
|
|
32
|
+
|
|
33
|
+
# matplotlib backends that cannot show a window — figures must be saved instead.
|
|
34
|
+
_NON_INTERACTIVE_BACKENDS: frozenset[str] = frozenset(
|
|
35
|
+
{"agg", "pdf", "svg", "ps", "cairo", "template", "pgf"}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def can_display() -> bool:
|
|
40
|
+
"""True when figures can be shown interactively: a tty and a GUI backend."""
|
|
41
|
+
if not sys.stdout.isatty():
|
|
42
|
+
return False
|
|
43
|
+
import matplotlib
|
|
44
|
+
|
|
45
|
+
return matplotlib.get_backend().lower() not in _NON_INTERACTIVE_BACKENDS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def grouped(n: int) -> str:
|
|
49
|
+
"""Integer with the active locale's digit-grouping separator.
|
|
50
|
+
|
|
51
|
+
Uses whatever locale the caller installed from the environment (US
|
|
52
|
+
``1,234``, German ``1.234``, French ``1 234``, ...). Falls back to the
|
|
53
|
+
plain integer if the locale provides no grouping (e.g. the ``C`` locale).
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
return locale.format_string("%d", n, grouping=True)
|
|
57
|
+
except (ValueError, locale.Error):
|
|
58
|
+
return str(n)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def safe_name(name: str) -> str:
|
|
62
|
+
"""Filesystem-safe stem for a section name."""
|
|
63
|
+
return "".join(c if (c.isalnum() or c in "-_.") else "_" for c in name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# CF temperature unit strings, shown as the compact "°C" symbol.
|
|
67
|
+
_CELSIUS_UNITS: frozenset[str] = frozenset(
|
|
68
|
+
{"degree_Celsius", "degrees_Celsius", "degC", "celsius", "Celsius"}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def var_label(ds: xr.Dataset, name: str) -> str:
|
|
73
|
+
"""Colorbar label from the variable's own CF attrs, falling back to name.
|
|
74
|
+
|
|
75
|
+
Units are rendered in curved brackets — ``long_name (unit)`` — consistently
|
|
76
|
+
across every plot, matching the curated ``_CBAR_LABEL`` overrides. A
|
|
77
|
+
dimensionless unit (``"1"`` — the CF/UDUNITS form for ratios such as
|
|
78
|
+
practical salinity, the mixing coefficient, or turbidity) is NOT rendered:
|
|
79
|
+
``(1)`` is noise on a plot, and the meaning lives in the long_name /
|
|
80
|
+
standard_name. Empty/absent units are likewise omitted (#38). Every
|
|
81
|
+
``degree_Celsius`` field shows the compact ``°C`` symbol.
|
|
82
|
+
"""
|
|
83
|
+
attrs = ds[name].attrs if name in ds else {}
|
|
84
|
+
long_name = attrs.get("long_name", name)
|
|
85
|
+
units = attrs.get("units")
|
|
86
|
+
if units in _CELSIUS_UNITS:
|
|
87
|
+
units = "°C"
|
|
88
|
+
return f"{long_name} ({units})" if units and units != "1" else str(long_name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Section spec + loading
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Section:
|
|
98
|
+
"""One depth-vs-x section: a time window + an x-axis method/params."""
|
|
99
|
+
|
|
100
|
+
name: str
|
|
101
|
+
method: str
|
|
102
|
+
start: np.datetime64 | None = None
|
|
103
|
+
stop: np.datetime64 | None = None
|
|
104
|
+
params: dict = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_time(value) -> np.datetime64 | None:
|
|
108
|
+
"""Parse an ISO-8601 string to a UTC datetime64.
|
|
109
|
+
|
|
110
|
+
A trailing ``Z`` is accepted (and stripped); a bare timestamp is treated as
|
|
111
|
+
UTC, matching the products' encoding. An explicit non-Z timezone offset is
|
|
112
|
+
rejected rather than silently shifted.
|
|
113
|
+
"""
|
|
114
|
+
if value is None:
|
|
115
|
+
return None
|
|
116
|
+
s = str(value).strip()
|
|
117
|
+
if not s:
|
|
118
|
+
return None
|
|
119
|
+
if s.endswith("Z"):
|
|
120
|
+
s = s[:-1]
|
|
121
|
+
# Handle an explicit timezone offset on the time-of-day part. The date/time
|
|
122
|
+
# separator may be 'T' OR a space: YAML resolves an unquoted '...Z'
|
|
123
|
+
# timestamp to a tz-AWARE datetime whose str() renders the zone as
|
|
124
|
+
# '+00:00' with a SPACE separator, so we must inspect the post-separator
|
|
125
|
+
# tail. A ZERO offset (Z / +00:00 / -00:00) is UTC and is accepted (and
|
|
126
|
+
# stripped, since numpy datetime64 rejects any offset); a NON-zero offset
|
|
127
|
+
# (e.g. +09:00 / -05:00) is rejected rather than silently shifted.
|
|
128
|
+
sep = "T" if "T" in s else (" " if " " in s else "")
|
|
129
|
+
tail = s.split(sep, 1)[-1] if sep else ""
|
|
130
|
+
off = re.search(r"([+-])(\d{2}):?(\d{2})?$", tail)
|
|
131
|
+
if off:
|
|
132
|
+
if int(off.group(2)) or int(off.group(3) or 0):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"time {value!r} must be UTC: use a trailing 'Z' or no offset, "
|
|
135
|
+
"not an explicit timezone"
|
|
136
|
+
)
|
|
137
|
+
s = s[: s.rindex(off.group(0))]
|
|
138
|
+
if sep == " ": # numpy datetime64 wants the ISO 'T' separator
|
|
139
|
+
s = s.replace(" ", "T", 1)
|
|
140
|
+
try:
|
|
141
|
+
return np.datetime64(s)
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
raise ValueError(f"could not parse time {value!r}: {exc}") from None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_params(method: str, params: dict, name: str) -> dict:
|
|
147
|
+
"""Check method-specific params and normalize units; return a clean dict."""
|
|
148
|
+
if method not in xaxis.METHODS:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"section {name!r}: unknown xaxis method {method!r}; "
|
|
151
|
+
f"choose from {list(xaxis.METHODS)}"
|
|
152
|
+
)
|
|
153
|
+
out: dict = {}
|
|
154
|
+
units = params.get("units", "km")
|
|
155
|
+
if units not in ("m", "km", "nm"):
|
|
156
|
+
raise ValueError(f"section {name!r}: units must be m/km/nm, got {units!r}")
|
|
157
|
+
out["units"] = units
|
|
158
|
+
if method == "distance_from_point":
|
|
159
|
+
point = params.get("point")
|
|
160
|
+
if point is None or len(point) != 2:
|
|
161
|
+
raise ValueError(f"section {name!r}: distance_from_point needs point: [lat, lon]")
|
|
162
|
+
out["point"] = [float(point[0]), float(point[1])]
|
|
163
|
+
elif method == "along_line":
|
|
164
|
+
wps = params.get("waypoints")
|
|
165
|
+
if wps is None or len(wps) < 2:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"section {name!r}: along_line needs waypoints: "
|
|
168
|
+
"[[lat, lon], [lat, lon], ...] (>= 2)"
|
|
169
|
+
)
|
|
170
|
+
out["waypoints"] = [[float(a), float(b)] for a, b in wps]
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _section_from_dict(raw: dict, idx: int) -> Section:
|
|
175
|
+
name = str(raw.get("name", f"section{idx:02d}"))
|
|
176
|
+
xa = dict(raw.get("xaxis") or {})
|
|
177
|
+
method = str(xa.pop("method", "time"))
|
|
178
|
+
params = validate_params(method, xa, name)
|
|
179
|
+
return Section(
|
|
180
|
+
name=name,
|
|
181
|
+
method=method,
|
|
182
|
+
start=parse_time(raw.get("start")),
|
|
183
|
+
stop=parse_time(raw.get("stop")),
|
|
184
|
+
params=params,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_sections(path: str) -> list[Section]:
|
|
189
|
+
"""Load and validate a sections YAML (standalone; not the pipeline config).
|
|
190
|
+
|
|
191
|
+
Schema::
|
|
192
|
+
|
|
193
|
+
sections:
|
|
194
|
+
- name: north_transect
|
|
195
|
+
start: "2025-01-20T00:00:00Z" # optional, UTC
|
|
196
|
+
stop: "2025-01-22T00:00:00Z" # optional
|
|
197
|
+
xaxis:
|
|
198
|
+
method: along_line # time|latitude|longitude|
|
|
199
|
+
# distance_from_point|along_line|
|
|
200
|
+
# signed_distance
|
|
201
|
+
units: km # distance methods
|
|
202
|
+
waypoints: [[18.5, 130.0], [20.0, 132.5]] # [lat, lon]
|
|
203
|
+
"""
|
|
204
|
+
from ruamel.yaml import YAML
|
|
205
|
+
|
|
206
|
+
with open(path, encoding="utf-8") as fh:
|
|
207
|
+
raw = YAML().load(fh) or {}
|
|
208
|
+
secs = raw.get("sections")
|
|
209
|
+
if not secs:
|
|
210
|
+
raise ValueError(f"{path}: must contain a non-empty 'sections:' list")
|
|
211
|
+
return [_section_from_dict(dict(s), i) for i, s in enumerate(secs)]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def select_sections(sections: list[Section], select: list[str]) -> list[Section]:
|
|
215
|
+
"""Keep only sections whose ``name`` was requested via ``--select``.
|
|
216
|
+
|
|
217
|
+
Each ``--select`` value may be a single name or a comma-separated list;
|
|
218
|
+
values are flattened. An unknown name is a hard error (rather than a silent
|
|
219
|
+
no-op) and lists the available names. Output preserves the file order.
|
|
220
|
+
"""
|
|
221
|
+
wanted: list[str] = []
|
|
222
|
+
for item in select:
|
|
223
|
+
wanted.extend(part.strip() for part in item.split(",") if part.strip())
|
|
224
|
+
available = [s.name for s in sections]
|
|
225
|
+
unknown = [w for w in wanted if w not in available]
|
|
226
|
+
if unknown:
|
|
227
|
+
raise SystemExit(
|
|
228
|
+
f"--select: no section named {unknown}; available: {available}"
|
|
229
|
+
)
|
|
230
|
+
wanted_set = set(wanted)
|
|
231
|
+
return [s for s in sections if s.name in wanted_set]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def xaxis_params_from_args(method: str, args: argparse.Namespace, name: str) -> dict:
|
|
235
|
+
"""Build (and validate) xaxis params for *method* from the CLI flags.
|
|
236
|
+
|
|
237
|
+
Used both for the ad-hoc single section and for the ``--xaxis`` override of
|
|
238
|
+
config sections, so spatial methods pull --point / --waypoints / --units
|
|
239
|
+
the same way in either path.
|
|
240
|
+
"""
|
|
241
|
+
params: dict = {"units": args.units}
|
|
242
|
+
if method == "distance_from_point":
|
|
243
|
+
if not args.point:
|
|
244
|
+
raise SystemExit("--xaxis distance_from_point requires --point LAT LON")
|
|
245
|
+
params["point"] = [float(args.point[0]), float(args.point[1])]
|
|
246
|
+
elif method == "along_line":
|
|
247
|
+
if not args.waypoints:
|
|
248
|
+
raise SystemExit(
|
|
249
|
+
"--xaxis along_line requires --waypoints 'lat,lon;lat,lon;...'"
|
|
250
|
+
)
|
|
251
|
+
params["waypoints"] = parse_waypoints(args.waypoints)
|
|
252
|
+
return validate_params(method, params, name)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def adhoc_section(args: argparse.Namespace) -> Section:
|
|
256
|
+
"""Build a single Section from ad-hoc CLI flags."""
|
|
257
|
+
method = args.xaxis or "time"
|
|
258
|
+
return Section(
|
|
259
|
+
name=args.name,
|
|
260
|
+
method=method,
|
|
261
|
+
start=parse_time(args.start),
|
|
262
|
+
stop=parse_time(args.stop),
|
|
263
|
+
params=xaxis_params_from_args(method, args, args.name),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def override_xaxis(sec: Section, args: argparse.Namespace) -> Section:
|
|
268
|
+
"""Return *sec* with its xaxis replaced by the CLI --xaxis (+ its params).
|
|
269
|
+
|
|
270
|
+
Keeps the section's name and time window; the method/params come from the
|
|
271
|
+
CLI. Lets ``--xaxis`` re-plot a whole sections file under a different axis.
|
|
272
|
+
"""
|
|
273
|
+
return Section(
|
|
274
|
+
name=sec.name,
|
|
275
|
+
method=args.xaxis,
|
|
276
|
+
start=sec.start,
|
|
277
|
+
stop=sec.stop,
|
|
278
|
+
params=xaxis_params_from_args(args.xaxis, args, sec.name),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def parse_clim(clim_args) -> dict[str, tuple[float, float]]:
|
|
283
|
+
"""Parse repeated ``--clim VAR MIN MAX`` into ``{var: (min, max)}``."""
|
|
284
|
+
out: dict[str, tuple[float, float]] = {}
|
|
285
|
+
for var, lo, hi in clim_args or []:
|
|
286
|
+
try:
|
|
287
|
+
out[str(var)] = (float(lo), float(hi))
|
|
288
|
+
except ValueError:
|
|
289
|
+
raise SystemExit(
|
|
290
|
+
f"--clim {var}: MIN/MAX must be numbers, got {lo!r} {hi!r}"
|
|
291
|
+
) from None
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_waypoints(text: str) -> list[list[float]]:
|
|
296
|
+
"""Parse ``'lat,lon;lat,lon;...'`` into ``[[lat, lon], ...]``."""
|
|
297
|
+
pts: list[list[float]] = []
|
|
298
|
+
for chunk in text.split(";"):
|
|
299
|
+
chunk = chunk.strip()
|
|
300
|
+
if not chunk:
|
|
301
|
+
continue
|
|
302
|
+
a, b = chunk.split(",")
|
|
303
|
+
pts.append([float(a), float(b)])
|
|
304
|
+
if len(pts) < 2:
|
|
305
|
+
raise SystemExit("--waypoints needs >= 2 'lat,lon' pairs separated by ';'")
|
|
306
|
+
return pts
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def resolve_sections(args: argparse.Namespace) -> list[Section]:
|
|
310
|
+
"""Build the section list from --sections (+ --select / --xaxis override) or ad-hoc.
|
|
311
|
+
|
|
312
|
+
Shared by subcommands: with --sections, load the YAML, apply --select, and
|
|
313
|
+
override every section's x-axis when an explicit --xaxis is given; without
|
|
314
|
+
--sections, build a single ad-hoc section (and --select is an error).
|
|
315
|
+
"""
|
|
316
|
+
if args.sections:
|
|
317
|
+
sections = load_sections(args.sections)
|
|
318
|
+
if args.select:
|
|
319
|
+
sections = select_sections(sections, args.select)
|
|
320
|
+
if args.xaxis is not None: # explicit --xaxis overrides every section
|
|
321
|
+
sections = [override_xaxis(s, args) for s in sections]
|
|
322
|
+
return sections
|
|
323
|
+
if args.select:
|
|
324
|
+
raise SystemExit("--select only applies together with --sections")
|
|
325
|
+
return [adhoc_section(args)]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def add_section_arguments(p: argparse.ArgumentParser) -> None:
|
|
329
|
+
"""Register the section / x-axis / color / display flags shared by subcommands.
|
|
330
|
+
|
|
331
|
+
Each subcommand adds its own product-reading and rendering flags (e.g.
|
|
332
|
+
--ctd-combo / --z-bin for scalar, --product for profiles).
|
|
333
|
+
"""
|
|
334
|
+
p.add_argument("--root", default=None,
|
|
335
|
+
help="perturb output root (contains the combo_NN/ products). "
|
|
336
|
+
"Required unless --config is given.")
|
|
337
|
+
from odas_tpw.perturb import resolve
|
|
338
|
+
resolve.add_resolve_args(p)
|
|
339
|
+
p.add_argument("--sections", default=None,
|
|
340
|
+
help="sections YAML (data-chopping + x-axis). If omitted, "
|
|
341
|
+
"a single ad-hoc section is built from the flags below.")
|
|
342
|
+
p.add_argument("--select", action="append", default=None, metavar="NAME",
|
|
343
|
+
help="plot only the named section(s) from --sections, by their "
|
|
344
|
+
"'name:' in the YAML (repeatable, or comma-separated). "
|
|
345
|
+
"Default: every section in the file.")
|
|
346
|
+
p.add_argument("--out-dir", default=None,
|
|
347
|
+
help="write <name>.png here instead of showing on screen. "
|
|
348
|
+
"Default: display interactively; with no display available "
|
|
349
|
+
"(no tty / non-GUI backend) figures are written into --root.")
|
|
350
|
+
p.add_argument("--var", dest="var", action="append", default=None,
|
|
351
|
+
help="variable to panel (repeatable; default: the product's "
|
|
352
|
+
"standard set)")
|
|
353
|
+
p.add_argument("--vmin", type=float, default=None,
|
|
354
|
+
help="color-scale minimum. Applies only with a single --var; "
|
|
355
|
+
"for multiple variables use --clim per variable.")
|
|
356
|
+
p.add_argument("--vmax", type=float, default=None,
|
|
357
|
+
help="color-scale maximum (single --var only; see --clim)")
|
|
358
|
+
p.add_argument("--clim", action="append", nargs=3, default=None,
|
|
359
|
+
metavar=("VAR", "MIN", "MAX"),
|
|
360
|
+
help="per-variable color limits (repeatable), e.g. "
|
|
361
|
+
"--clim epsilonMean 1e-10 1e-7. Wins over --vmin/--vmax.")
|
|
362
|
+
p.add_argument("--name", default="section", help="ad-hoc section name")
|
|
363
|
+
p.add_argument("--xaxis", choices=xaxis.METHODS, default=None,
|
|
364
|
+
help="x-axis method. Without --sections, builds one ad-hoc "
|
|
365
|
+
"section (default: time). With --sections, overrides "
|
|
366
|
+
"every section's x-axis when given.")
|
|
367
|
+
p.add_argument("--start", default=None, help="ad-hoc window start (UTC ISO-8601)")
|
|
368
|
+
p.add_argument("--stop", default=None, help="ad-hoc window stop (UTC ISO-8601)")
|
|
369
|
+
p.add_argument("--point", nargs=2, type=float, default=None, metavar=("LAT", "LON"),
|
|
370
|
+
help="reference point for --xaxis distance_from_point")
|
|
371
|
+
p.add_argument("--waypoints", default=None,
|
|
372
|
+
help="polyline for --xaxis along_line: 'lat,lon;lat,lon;...'")
|
|
373
|
+
p.add_argument("--units", choices=("m", "km", "nm"), default="km",
|
|
374
|
+
help="distance units for spatial x-axes (default: km)")
|
|
375
|
+
add_output_arguments(p)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def positive_int(s: str) -> int:
|
|
379
|
+
"""argparse ``type`` for a strictly-positive integer (e.g. ``--dpi``).
|
|
380
|
+
|
|
381
|
+
Raises ``ValueError`` (not ``ArgumentTypeError``) on a non-positive or
|
|
382
|
+
non-integer value so the same check fires both at the CLI *and* through the
|
|
383
|
+
figure driver's ``_coerce`` (which calls the action's ``type`` and catches
|
|
384
|
+
``ValueError``) — a 0/negative/float dpi fails up front, not at render time.
|
|
385
|
+
"""
|
|
386
|
+
v = int(s)
|
|
387
|
+
if v <= 0:
|
|
388
|
+
raise ValueError(f"must be a positive integer, got {v}")
|
|
389
|
+
return v
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def add_output_arguments(p: argparse.ArgumentParser, *, title: bool = True) -> None:
|
|
393
|
+
"""Register the shared figure-output flags (figsize / dpi [/ title]).
|
|
394
|
+
|
|
395
|
+
Honored by every subcommand's renderer and by the ``figure`` batch driver
|
|
396
|
+
(per-figure ``figsize``/``dpi``/``title``). ``title=False`` for a subcommand
|
|
397
|
+
that already registers its own ``--title`` (eps-chi).
|
|
398
|
+
"""
|
|
399
|
+
p.add_argument("--figsize", nargs=2, type=float, default=None, metavar=("W", "H"),
|
|
400
|
+
help="figure size in inches, e.g. --figsize 11 9 "
|
|
401
|
+
"(default: preset-specific).")
|
|
402
|
+
p.add_argument("--ncols", type=positive_int, default=None,
|
|
403
|
+
help="arrange the variable panels in this many columns "
|
|
404
|
+
"(default: preset-specific — profiles a 3-column and "
|
|
405
|
+
"scalar a 2-column grid, others a single vertical stack); "
|
|
406
|
+
"e.g. 2 with four variables gives a 2x2 grid. Sections "
|
|
407
|
+
"stay one image each.")
|
|
408
|
+
p.add_argument("--dpi", type=positive_int, default=None,
|
|
409
|
+
help="raster resolution for saved PNG/PDF (default: 150).")
|
|
410
|
+
if title:
|
|
411
|
+
p.add_argument("--title", default=None,
|
|
412
|
+
help="figure title (default: derived from the data).")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def fig_dpi(args: argparse.Namespace) -> int:
|
|
416
|
+
"""Raster resolution for a saved figure: ``--dpi`` or the 150 default."""
|
|
417
|
+
return getattr(args, "dpi", None) or 150
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@contextlib.contextmanager
|
|
421
|
+
def close_new_figs_on_error() -> Iterator[None]:
|
|
422
|
+
"""Close any pyplot figure created inside the block if it raises.
|
|
423
|
+
|
|
424
|
+
A figure built by ``plt.subplots`` registers in pyplot's global manager
|
|
425
|
+
immediately, so an exception while populating it (a gridding/limits error)
|
|
426
|
+
before it is returned/yielded would leave it open forever — the caller's
|
|
427
|
+
cleanup can't reach a figure it never received. Wrap each figure build in
|
|
428
|
+
this so a build-time error closes the orphan(s) and re-raises; figures that
|
|
429
|
+
existed before the block (already handed to the caller) are left untouched.
|
|
430
|
+
"""
|
|
431
|
+
import matplotlib.pyplot as plt
|
|
432
|
+
|
|
433
|
+
before = set(plt.get_fignums())
|
|
434
|
+
try:
|
|
435
|
+
yield
|
|
436
|
+
except BaseException:
|
|
437
|
+
for num in set(plt.get_fignums()) - before:
|
|
438
|
+
plt.close(num)
|
|
439
|
+
raise
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@contextlib.contextmanager
|
|
443
|
+
def closing_figs(
|
|
444
|
+
figs: Iterable[tuple[str, Any]],
|
|
445
|
+
) -> Iterator[Iterable[tuple[str, Any]]]:
|
|
446
|
+
"""Yield *figs*, then close it on exit if it is a generator.
|
|
447
|
+
|
|
448
|
+
A ``build_figures`` generator holds an open dataset until its ``finally``
|
|
449
|
+
runs (on exhaustion or close). Wrapping consumption in this guarantees that
|
|
450
|
+
handle is released deterministically even if a consumer stops early (a save
|
|
451
|
+
error mid-stream), rather than waiting on GC. A plain list/iterator with no
|
|
452
|
+
``close`` is left untouched.
|
|
453
|
+
"""
|
|
454
|
+
try:
|
|
455
|
+
yield figs
|
|
456
|
+
finally:
|
|
457
|
+
close = getattr(figs, "close", None)
|
|
458
|
+
if callable(close):
|
|
459
|
+
close()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def save_or_show(figs: Iterable[tuple[str, Any]], out_dir: str | None,
|
|
463
|
+
dpi: int) -> int:
|
|
464
|
+
"""Consume a ``(stem, Figure)`` iterable and return how many it handled.
|
|
465
|
+
|
|
466
|
+
With *out_dir* set, write ``<out_dir>/<stem>.png`` **streaming** — one open
|
|
467
|
+
figure at a time, closing each before the next is pulled (bounded memory,
|
|
468
|
+
and a save error can't leak the not-yet-built figures). With *out_dir* None,
|
|
469
|
+
collect every figure (they must all be open together) and ``show()`` them.
|
|
470
|
+
Always closes the figures it created, even on error.
|
|
471
|
+
"""
|
|
472
|
+
import matplotlib.pyplot as plt
|
|
473
|
+
|
|
474
|
+
if out_dir is None: # interactive: all figures open together for show()
|
|
475
|
+
collected = []
|
|
476
|
+
try:
|
|
477
|
+
for _stem, fig in figs:
|
|
478
|
+
collected.append(fig)
|
|
479
|
+
except BaseException:
|
|
480
|
+
for fig in collected:
|
|
481
|
+
plt.close(fig)
|
|
482
|
+
raise
|
|
483
|
+
if collected:
|
|
484
|
+
plt.show() # blocks until the user closes the window(s)
|
|
485
|
+
plt.close("all")
|
|
486
|
+
return len(collected)
|
|
487
|
+
|
|
488
|
+
count = 0
|
|
489
|
+
for stem, fig in figs: # save path: at most one figure open at a time
|
|
490
|
+
try:
|
|
491
|
+
out = os.path.join(out_dir, f"{stem}.png")
|
|
492
|
+
fig.savefig(out, dpi=dpi)
|
|
493
|
+
print(f"Wrote {out}")
|
|
494
|
+
count += 1
|
|
495
|
+
finally:
|
|
496
|
+
plt.close(fig)
|
|
497
|
+
return count
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def single_var_limit_guard(args: argparse.Namespace, variables: list[str]) -> None:
|
|
501
|
+
"""Reject global --vmin/--vmax when more than one variable is plotted."""
|
|
502
|
+
if (args.vmin is not None or args.vmax is not None) and len(variables) > 1:
|
|
503
|
+
raise SystemExit(
|
|
504
|
+
"--vmin/--vmax apply only with a single --var; use "
|
|
505
|
+
"--clim VAR MIN MAX for per-variable limits"
|
|
506
|
+
)
|