microstructure-tpw 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. odas_tpw/scor160/spectral.py +482 -0
@@ -0,0 +1,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
+ )