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,679 @@
1
+ # Jun-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """``perturb-plot figure`` — drive many figures from one YAML spec.
3
+
4
+ A *figure spec* lists figures, each naming a **preset** (``scalar`` / ``profiles``
5
+ / ``epsilon`` / ``chi`` / ``mixing`` / ``eps-chi`` — the ``perturb-plot``
6
+ subcommands) plus that subcommand's own options. The driver resolves output
7
+ directories from a perturb config (see :mod:`odas_tpw.perturb.resolve`),
8
+ compiles each figure entry into the ``argparse.Namespace`` the chosen ``run``
9
+ already accepts, and runs it — so every plotting kernel and existing behavior
10
+ is reused, not reimplemented.
11
+
12
+ ```yaml
13
+ source: # exactly one of config / root
14
+ config: perturb.1.yaml
15
+ # output_root: ~/Desktop/VMP_results # optional override of where to look
16
+ # root: ~/Desktop/VMP_results # opt-out of config resolution
17
+
18
+ output_dir: figs/ # one subdir per figure is written here
19
+
20
+ sections: # optional; a file ref or an inline list
21
+ file: sections.yaml
22
+
23
+ figures:
24
+ - {name: ts, preset: scalar, vars: [JAC_T, SP, sigma0], depth_max: 150}
25
+ - {name: overview, preset: eps-chi, gap_seconds: 600}
26
+ - {name: mixing, preset: mixing, section: "*"}
27
+ ```
28
+
29
+ A figure entry's keys are exactly the preset subcommand's options (``vars`` is
30
+ sugar for repeated ``--var``; ``clim`` is a ``{VAR: [min, max]}`` map). ``eps-chi``
31
+ has no x-axis, so ``section``/``vars``/``clim`` are rejected for it.
32
+
33
+ With no output destination (neither the spec nor the CLI sets ``output_dir`` /
34
+ ``output_pdf``) the figures are shown on screen when a display is available,
35
+ falling back to a PNG tree in the cwd when it is not (headless / no tty / a
36
+ non-GUI backend) — mirroring the ``scalar``/``profiles`` subcommands. Set
37
+ ``output_dir`` or ``output_pdf`` to force writing files.
38
+
39
+ The spec's ``source`` (``config``/``root``), output destination
40
+ (``output_dir``/``output_pdf``), and ``sections:`` block can be overridden from
41
+ the command line — e.g. ``perturb-plot figure --spec fig.yaml --config
42
+ perturb.2.yaml --output-dir figs/run2`` — so one spec renders the same figures
43
+ across several perturb runs for comparison without editing the file.
44
+
45
+ Two independent subsetting flags (note they select different things):
46
+
47
+ * ``--figure NAME`` (repeatable) renders only the named *figures* from the
48
+ spec's ``figures:`` list.
49
+ * ``--sections SECTIONS.YAML`` replaces the spec's ``sections:`` block, and
50
+ ``--select NAME`` (repeatable, comma-separated) renders only the named
51
+ *sections* — narrowing every figure's own ``section:`` choice, exactly as
52
+ ``perturb-plot scalar --select`` does. ``eps-chi`` has no x-axis, so it
53
+ ignores ``--select``; a figure narrowed to no sections is skipped.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import argparse
59
+ import contextlib
60
+ import os
61
+ import tempfile
62
+ from pathlib import Path
63
+ from typing import Any
64
+
65
+ from ruamel.yaml import YAML
66
+ from ruamel.yaml.error import YAMLError
67
+
68
+ from odas_tpw.perturb.plot import eps_chi, overview, profiles, scalar, sections
69
+
70
+ # preset name -> object providing add_arguments/build_figures/run. The binned
71
+ # (bin, profile) presets are ProductView instances (one product each); scalar
72
+ # and eps-chi are plain modules. All duck-type the same surface.
73
+ _PRESETS: dict[str, Any] = {
74
+ "scalar": scalar,
75
+ "profiles": profiles.PROFILES,
76
+ "epsilon": profiles.EPSILON,
77
+ "chi": profiles.CHI,
78
+ "mixing": profiles.MIXING,
79
+ "eps-chi": eps_chi,
80
+ "overview": overview,
81
+ }
82
+ _PRESET_DIR = Path(__file__).parent / "presets"
83
+
84
+ # Friendly figure-spec keys that map to a differently-named Namespace attr.
85
+ _KEY_ALIAS = {"vars": "var"}
86
+ # Figure keys handled specially (not copied generically onto the Namespace).
87
+ _CONTROL_KEYS = {"name", "preset", "section"}
88
+ # Keys set by the source/CLI/driver — a figure may not override them.
89
+ _RESERVED_KEYS = {"config", "root", "strict", "latest", "out", "out_dir",
90
+ "sections", "select"}
91
+
92
+
93
+ class SpecError(SystemExit):
94
+ """A user error in the figure spec (raised as a clean CLI exit)."""
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Spec loading / validation
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _load_spec(path: str) -> dict:
102
+ try:
103
+ with open(path) as fh:
104
+ spec = YAML(typ="safe").load(fh)
105
+ except (OSError, YAMLError) as exc:
106
+ raise SpecError(f"cannot read figure spec {path}: {exc}") from exc
107
+ if not isinstance(spec, dict):
108
+ raise SpecError(f"figure spec {path}: top level must be a mapping")
109
+ return spec
110
+
111
+
112
+ def _validate_source(spec: dict) -> dict:
113
+ source = spec.get("source")
114
+ if not isinstance(source, dict):
115
+ raise SpecError("figure spec: a 'source' mapping is required")
116
+ has_config = bool(source.get("config"))
117
+ has_root = bool(source.get("root"))
118
+ if has_config == has_root: # neither or both
119
+ raise SpecError("figure spec: source needs exactly one of 'config' or 'root'")
120
+ return source
121
+
122
+
123
+ def _apply_cli_overrides(spec: dict, args: argparse.Namespace) -> None:
124
+ """Let CLI flags override the spec's source / output destination, in place.
125
+
126
+ Enables comparing perturb runs from a single spec: ``--config`` / ``--root``
127
+ swap where directories are resolved from, and ``--output-dir`` /
128
+ ``--output-pdf`` redirect where the figures land — all without editing the
129
+ spec file. Validation of the resulting values is left to
130
+ :func:`_validate_source` / :func:`_output_config`.
131
+ """
132
+ cli_config = getattr(args, "config", None)
133
+ cli_root = getattr(args, "root", None)
134
+ if cli_config and cli_root:
135
+ raise SpecError("figure: --config and --root are mutually exclusive")
136
+ if cli_config or cli_root:
137
+ src = spec.get("source")
138
+ # Keep a spec source's output_root (a search-location override) when only
139
+ # the config/root selector is swapped; drop whichever selector we replace.
140
+ src = dict(src) if isinstance(src, dict) else {}
141
+ src.pop("config", None)
142
+ src.pop("root", None)
143
+ if cli_root:
144
+ src.pop("output_root", None) # a raw root is itself the search dir
145
+ src["root"] = cli_root
146
+ else:
147
+ src["config"] = cli_config
148
+ spec["source"] = src
149
+
150
+ cli_dir = getattr(args, "output_dir", None)
151
+ cli_pdf = getattr(args, "output_pdf", None)
152
+ if cli_dir and cli_pdf:
153
+ raise SpecError("figure: --output-dir and --output-pdf are mutually exclusive")
154
+ if cli_dir:
155
+ spec["output_dir"] = cli_dir
156
+ spec.pop("output_pdf", None)
157
+ elif cli_pdf:
158
+ spec["output_pdf"] = cli_pdf
159
+ spec.pop("output_dir", None)
160
+
161
+ # --sections replaces the spec's whole sections block (like --config swaps
162
+ # the source), so one spec can be re-sectioned from the CLI without editing.
163
+ cli_sections = getattr(args, "sections", None)
164
+ if cli_sections:
165
+ spec["sections"] = {"file": cli_sections}
166
+
167
+
168
+ def _sections_file(spec: dict, tmp: list[str]) -> str | None:
169
+ """Resolve the spec's optional sections to a file path the subcommands read.
170
+
171
+ Accepts ``sections: {file: PATH}`` or an inline ``sections: [ ... ]`` list
172
+ (written to a temp file). Returns None when absent.
173
+ """
174
+ sections = spec.get("sections")
175
+ if sections is None:
176
+ return None
177
+ if isinstance(sections, dict) and "file" in sections:
178
+ return str(sections["file"])
179
+ if isinstance(sections, list):
180
+ fd, name = tempfile.mkstemp(suffix=".yaml", prefix="figspec_sections_")
181
+ tmp.append(name) # register before writing so a write error still cleans up
182
+ with os.fdopen(fd, "w") as fh:
183
+ YAML(typ="safe").dump({"sections": sections}, fh)
184
+ return name
185
+ raise SpecError("figure spec: 'sections' must be {file: PATH} or a list")
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # figure entry -> argparse.Namespace for the preset subcommand
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def _adapt(key: str, value: Any) -> Any:
193
+ """Convert a friendly spec value to the Namespace form the subcommand uses."""
194
+ if key in ("vars", "var"):
195
+ return list(value) if isinstance(value, (list, tuple)) else [value]
196
+ if key == "clim":
197
+ if not isinstance(value, dict):
198
+ raise SpecError("figure 'clim' must be a {VAR: [min, max]} mapping")
199
+ out = []
200
+ for var, pair in value.items():
201
+ if not isinstance(pair, (list, tuple)) or len(pair) != 2:
202
+ raise SpecError(f"figure 'clim' {var!r}: expected [min, max], got {pair!r}")
203
+ out.append([str(var), str(pair[0]), str(pair[1])])
204
+ return out
205
+ return value
206
+
207
+
208
+ def _coerce(action: argparse.Action, key: str, value: Any, fig_name: str) -> Any:
209
+ """Apply the parser action's coercion/validation to a YAML value, so a spec
210
+ gets the same handling the CLI would: ``vmin: 1e-7`` floats, ``product:
211
+ bogus`` is rejected, ``apply_qc: "false"`` is rejected (truthy string), a
212
+ list for a scalar option is rejected, and an nargs option (e.g. ``point:``
213
+ for ``--point nargs=2``) must be a list of the declared length."""
214
+ # store_true/false: the value is the destination boolean directly. A bare
215
+ # string ("false") would be truthy downstream, so require a real bool.
216
+ if isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction)):
217
+ if not isinstance(value, bool):
218
+ raise SpecError(
219
+ f"figure {fig_name!r}: option {key!r} expects true/false, got {value!r}"
220
+ )
221
+ return value
222
+
223
+ # A list is only valid for list-producing actions (append, or nargs +/*/N).
224
+ takes_list = (
225
+ isinstance(action, argparse._AppendAction)
226
+ or action.nargs in ("*", "+")
227
+ or isinstance(action.nargs, int)
228
+ )
229
+ if isinstance(value, list) and not takes_list:
230
+ raise SpecError(
231
+ f"figure {fig_name!r}: option {key!r} takes a single value, got list {value!r}"
232
+ )
233
+
234
+ # An nargs option (e.g. --point nargs=2) consumes a fixed/variable number of
235
+ # values in ONE go — so the spec value must be a list of the right length,
236
+ # exactly as the CLI enforces. (append is different: a list there means
237
+ # several invocations, each consuming nargs values, so it isn't length-
238
+ # checked here — its per-invocation shape is handled by `_adapt`.)
239
+ nargs_list = takes_list and not isinstance(action, argparse._AppendAction)
240
+ if nargs_list and not isinstance(value, list):
241
+ raise SpecError(
242
+ f"figure {fig_name!r}: option {key!r} expects a list of values, "
243
+ f"got {value!r}"
244
+ )
245
+ if nargs_list and isinstance(value, list):
246
+ if isinstance(action.nargs, int) and len(value) != action.nargs:
247
+ raise SpecError(
248
+ f"figure {fig_name!r}: option {key!r} expects exactly "
249
+ f"{action.nargs} values, got {len(value)}: {value!r}"
250
+ )
251
+ if action.nargs == "+" and not value:
252
+ raise SpecError(
253
+ f"figure {fig_name!r}: option {key!r} requires at least one value"
254
+ )
255
+
256
+ typ, choices = action.type, action.choices
257
+
258
+ def one(v: Any) -> Any:
259
+ # A YAML bool reaching here is for a typed/positional option (store_true/
260
+ # store_false were handled above), never valid — reject it BEFORE
261
+ # coercion so `dpi: true` / `figsize: [true, 9]` / `depth_max: true` fail
262
+ # with a clean SpecError instead of crashing matplotlib at render time.
263
+ if isinstance(v, bool):
264
+ raise SpecError(
265
+ f"figure {fig_name!r}: option {key!r}={v!r} is a boolean; "
266
+ f"expected {getattr(typ, '__name__', 'a value')}"
267
+ )
268
+ # str() first so coercion matches argparse exactly (int('1.5') raises —
269
+ # no silent float->int truncation).
270
+ if callable(typ) and v is not None:
271
+ try:
272
+ v = typ(str(v))
273
+ except (ValueError, TypeError) as exc:
274
+ tname = getattr(typ, "__name__", str(typ))
275
+ raise SpecError(
276
+ f"figure {fig_name!r}: option {key!r}={v!r} is not a valid {tname}"
277
+ ) from exc
278
+ if choices is not None and v not in choices:
279
+ raise SpecError(
280
+ f"figure {fig_name!r}: option {key!r}={v!r} must be one of "
281
+ f"{sorted(str(c) for c in choices)}"
282
+ )
283
+ return v
284
+
285
+ return [one(v) for v in value] if isinstance(value, list) else one(value)
286
+
287
+
288
+ def _apply_section(args: argparse.Namespace, preset: str, figure: dict, sections_file,
289
+ section_select: frozenset[str] | None = None) -> bool:
290
+ """Set the leaf's section selection from the figure's own ``section:`` key,
291
+ narrowed by the global ``--select`` (*section_select*: a validated set of
292
+ section names, or None when ``--select`` was not given).
293
+
294
+ Returns True when the figure still has sections to render, False when the
295
+ global ``--select`` leaves this figure with none (the caller then skips it).
296
+ ``eps-chi`` has no x-axis, so sections do not apply: the global ``--select``
297
+ is a no-op and a per-figure ``section:`` is rejected.
298
+ """
299
+ section = figure.get("section")
300
+ if preset == "eps-chi":
301
+ if section is not None:
302
+ raise SpecError(
303
+ f"figure {figure.get('name')!r}: preset 'eps-chi' has no x-axis; "
304
+ f"'section' is not allowed."
305
+ )
306
+ return True # global --select does not apply to a section-less preset
307
+ if sections_file is not None:
308
+ args.sections = sections_file
309
+ # The figure's own choice: None / "*" mean "every section in the file".
310
+ if section is None or section == "*":
311
+ own: list[str] | None = None
312
+ elif isinstance(section, str):
313
+ own = [section]
314
+ elif isinstance(section, list):
315
+ own = [str(s) for s in section]
316
+ else:
317
+ raise SpecError(f"figure {figure.get('name')!r}: bad 'section' {section!r}")
318
+ # Narrow by the global --select. When the figure takes "all", the global set
319
+ # IS the selection; otherwise keep only the figure's sections that are in it.
320
+ if section_select is None:
321
+ effective = own
322
+ elif own is None:
323
+ effective = sorted(section_select)
324
+ else:
325
+ effective = [s for s in own if s in section_select]
326
+ if not effective:
327
+ return False # nothing survives the --select narrowing; skip figure
328
+ args.select = effective # None = every section
329
+ if args.select and not sections_file:
330
+ raise SpecError(
331
+ f"figure {figure.get('name')!r}: 'section' names a section but the spec "
332
+ f"has no 'sections' block."
333
+ )
334
+ return True
335
+
336
+
337
+ def _build_args(figure: dict, source: dict, sections_file, output_dir: Path,
338
+ strict: bool, latest: bool, section_select: frozenset[str] | None = None,
339
+ *, make_output: bool = True) -> tuple[Any, argparse.Namespace] | None:
340
+ """Compile a figure entry into ``(preset_module, Namespace)``.
341
+
342
+ Returns ``None`` when the global ``--select`` (*section_select*) narrows this
343
+ figure to no sections — the caller skips it (no output dir is created).
344
+
345
+ With ``make_output`` (PNG mode) the per-figure output path is assigned and
346
+ its subdir created; the PDF driver passes ``make_output=False`` because it
347
+ streams ``build_figures()`` into one document and needs no subdirs.
348
+ """
349
+ preset = figure.get("preset")
350
+ name = str(figure.get("name") or preset)
351
+ if preset not in _PRESETS:
352
+ raise SpecError(
353
+ f"figure {name!r}: unknown preset {preset!r}; "
354
+ f"choose from {sorted(_PRESETS)}"
355
+ )
356
+ mod = _PRESETS[preset]
357
+ p = argparse.ArgumentParser(add_help=False)
358
+ mod.add_arguments(p)
359
+ args = p.parse_args([])
360
+ dest_map = {a.dest: a for a in p._actions if a.dest and a.dest != "help"}
361
+
362
+ # Source resolution (consumed by resolve.resolve_for_args inside run()).
363
+ args.config = source.get("config")
364
+ args.root = source.get("root") or source.get("output_root")
365
+ args.strict = strict
366
+ args.latest = latest
367
+
368
+ # Generic per-preset options — coerced/validated by the subcommand's own
369
+ # argparse action (type + choices), as if they had come from the CLI.
370
+ for key, value in figure.items():
371
+ if key in _CONTROL_KEYS:
372
+ continue
373
+ # Accept the CLI spelling (hyphens) as well as the dest (underscores).
374
+ attr = _KEY_ALIAS.get(key, key).replace("-", "_")
375
+ if attr in _RESERVED_KEYS:
376
+ raise SpecError(
377
+ f"figure {name!r}: {key!r} is set by source/CLI, not per figure"
378
+ )
379
+ if attr not in dest_map:
380
+ valid = sorted(k for k in dest_map if k not in _RESERVED_KEYS)
381
+ raise SpecError(
382
+ f"figure {name!r}: option {key!r} is not valid for preset "
383
+ f"{preset!r}. Valid options: {valid}"
384
+ )
385
+ setattr(args, attr, _coerce(dest_map[attr], key, _adapt(key, value), name))
386
+
387
+ if not _apply_section(args, preset, figure, sections_file, section_select):
388
+ return None # --select narrowed this figure to no sections; caller skips
389
+
390
+ if make_output:
391
+ # PNG mode: one subdir per figure so different figures never collide.
392
+ fig_dir = output_dir / _safe(name)
393
+ fig_dir.mkdir(parents=True, exist_ok=True)
394
+ if preset == "eps-chi":
395
+ args.out = str(fig_dir / "eps_chi.png")
396
+ else:
397
+ args.out_dir = str(fig_dir)
398
+ return mod, args
399
+
400
+
401
+ def _safe(name: str) -> str:
402
+ return "".join(c if (c.isalnum() or c in "-_.") else "_" for c in str(name))
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # CLI
407
+ # ---------------------------------------------------------------------------
408
+
409
+ def add_arguments(p: argparse.ArgumentParser) -> None:
410
+ """Register CLI flags for the figure subcommand on *p*."""
411
+ p.add_argument("--spec", help="figure-spec YAML (layout + contents).")
412
+ p.add_argument("--figure", action="append", default=None, metavar="NAME",
413
+ help="render only the named figure(s) from the spec's 'figures:' "
414
+ "list (repeatable). Default: all.")
415
+ p.add_argument("--sections", default=None, metavar="SECTIONS.YAML",
416
+ help="override the spec's 'sections:' block with this sections "
417
+ "YAML (same file 'perturb-plot scalar --sections' reads).")
418
+ p.add_argument("--select", action="append", default=None, metavar="NAME",
419
+ help="render only the named section(s) (repeatable, or "
420
+ "comma-separated), narrowing every figure's own section "
421
+ "choice. Default: every section. Requires sections.")
422
+ p.add_argument("--config", metavar="PERTURB.YAML", default=None,
423
+ help="override the spec's source config (compare/contrast "
424
+ "perturb runs from one spec). Mutually exclusive with --root.")
425
+ p.add_argument("--root", metavar="DIR", default=None,
426
+ help="override the spec's source: resolve dirs by newest "
427
+ "{stage}_NN under DIR instead of a config. "
428
+ "Mutually exclusive with --config.")
429
+ p.add_argument("--output-dir", metavar="DIR", default=None,
430
+ help="override the spec's output_dir (one PNG tree). "
431
+ "Mutually exclusive with --output-pdf.")
432
+ p.add_argument("--output-pdf", metavar="PDF", default=None,
433
+ help="override the spec's output_pdf (one combined PDF). "
434
+ "Mutually exclusive with --output-dir.")
435
+ p.add_argument("--strict", action="store_true",
436
+ help="error (don't fall back) when a config signature doesn't match.")
437
+ p.add_argument("--latest", action="store_true",
438
+ help="use the newest {stage}_NN even if no signature matches.")
439
+ p.add_argument("--list-presets", action="store_true",
440
+ help="list the bundled example presets and exit.")
441
+ p.add_argument("--dump-preset", metavar="NAME",
442
+ help="print the bundled example spec for a preset and exit.")
443
+
444
+
445
+ def _list_presets() -> None:
446
+ print("Bundled example figure specs (copy and edit):")
447
+ for f in sorted(_PRESET_DIR.glob("*.yaml")):
448
+ print(f" {f.stem}")
449
+ print("\nDump one with: perturb-plot figure --dump-preset NAME")
450
+
451
+
452
+ def _dump_preset(name: str) -> None:
453
+ path = _PRESET_DIR / f"{name}.yaml"
454
+ if not path.exists():
455
+ avail = ", ".join(sorted(p.stem for p in _PRESET_DIR.glob("*.yaml")))
456
+ raise SpecError(f"no example preset {name!r}; available: {avail}")
457
+ print(path.read_text())
458
+
459
+
460
+ def _output_config(spec: dict) -> tuple[str | None, str | None, int | None]:
461
+ """Validate the spec's output controls.
462
+
463
+ At most one of ``output_dir`` (one PNG tree) or ``output_pdf`` (one combined
464
+ multipage PDF); plus an optional default ``dpi`` applied to any figure that
465
+ doesn't set its own. Both may be ``None`` -- with no output destination the
466
+ figures are displayed on screen when a display is available, else written to
467
+ a PNG tree in the cwd (decided in :func:`run`).
468
+ """
469
+ out_dir = spec.get("output_dir")
470
+ out_pdf = spec.get("output_pdf")
471
+ if out_dir is not None and out_pdf is not None:
472
+ raise SpecError("figure spec: set only one of 'output_dir' or 'output_pdf'")
473
+ dpi = spec.get("dpi")
474
+ if dpi is not None:
475
+ if isinstance(dpi, bool) or not isinstance(dpi, int):
476
+ raise SpecError(f"figure spec: 'dpi' must be an integer, got {dpi!r}")
477
+ if dpi <= 0:
478
+ raise SpecError(f"figure spec: 'dpi' must be a positive integer, got {dpi}")
479
+ return out_dir, out_pdf, dpi
480
+
481
+
482
+ def _compiled_figures(figures, source, sections_file, args, wanted, default_dpi,
483
+ output_dir: Path, section_select, *, make_output: bool):
484
+ """Yield ``(name, preset_module, Namespace)`` for each selected figure, with
485
+ the spec's default ``dpi`` filled in where the figure didn't set its own.
486
+
487
+ A figure the global ``--select`` narrows to no sections is skipped (a note is
488
+ printed) rather than rendered empty."""
489
+ for figure in figures:
490
+ name = figure.get("name") or figure.get("preset")
491
+ if wanted and name not in wanted:
492
+ continue
493
+ built = _build_args(
494
+ figure, source, sections_file, output_dir, args.strict, args.latest,
495
+ section_select, make_output=make_output,
496
+ )
497
+ if built is None:
498
+ print(f"figure {name!r}: no sections remain after --select; skipped")
499
+ continue
500
+ mod, fig_args = built
501
+ if getattr(fig_args, "dpi", None) is None:
502
+ fig_args.dpi = default_dpi
503
+ print(f"=== figure {name!r} (preset {figure.get('preset')!r}) ===")
504
+ yield name, mod, fig_args
505
+
506
+
507
+ def _resolve_section_select(select, sections_file) -> frozenset[str] | None:
508
+ """Validate the global ``--select`` names against the sections file and return
509
+ them as a set (or None when ``--select`` was not given).
510
+
511
+ ``--select`` needs sections to select from — either the spec's ``sections:``
512
+ block or a ``--sections`` override. Unknown names are rejected here (via
513
+ ``sections.select_sections``) so the error names them, rather than silently
514
+ narrowing every figure to nothing.
515
+ """
516
+ if not select:
517
+ return None
518
+ if sections_file is None:
519
+ raise SpecError(
520
+ "figure --select requires sections: add a 'sections:' block to the "
521
+ "spec or pass --sections SECTIONS.YAML"
522
+ )
523
+ available = sections.load_sections(sections_file)
524
+ chosen = sections.select_sections(available, select) # raises on unknown name
525
+ return frozenset(s.name for s in chosen)
526
+
527
+
528
+ def run(args: argparse.Namespace) -> str:
529
+ """Render every figure in the spec. Returns the PNG dir or the PDF path."""
530
+ if args.list_presets:
531
+ _list_presets()
532
+ return ""
533
+ if args.dump_preset:
534
+ _dump_preset(args.dump_preset)
535
+ return ""
536
+ if not args.spec:
537
+ raise SpecError("figure: --spec is required (or use --list-presets/--dump-preset)")
538
+
539
+ spec = _load_spec(args.spec)
540
+ _apply_cli_overrides(spec, args) # --config/--root/--output-* win over the spec
541
+ source = _validate_source(spec)
542
+ figures = spec.get("figures")
543
+ if not isinstance(figures, list) or not figures:
544
+ raise SpecError("figure spec: a non-empty 'figures' list is required")
545
+ out_dir, out_pdf, default_dpi = _output_config(spec)
546
+
547
+ # Up-front: every entry is a mapping, and no two figures collide on their
548
+ # name (the PNG subdir / the --select key), post-sanitization.
549
+ names: list[Any] = []
550
+ safe_seen: dict[str, Any] = {}
551
+ for f in figures:
552
+ if not isinstance(f, dict):
553
+ raise SpecError("figure spec: each figures[] entry must be a mapping")
554
+ nm = f.get("name") or f.get("preset")
555
+ names.append(nm)
556
+ sn = _safe(str(nm))
557
+ if sn in safe_seen:
558
+ raise SpecError(
559
+ f"figure spec: figures {safe_seen[sn]!r} and {nm!r} both map to "
560
+ f"the same name {sn!r} — give each figure a unique 'name:'."
561
+ )
562
+ safe_seen[sn] = nm
563
+
564
+ wanted = set(args.figure) if args.figure else None
565
+ if wanted:
566
+ missing = wanted - set(names)
567
+ if missing:
568
+ raise SpecError(f"figure --figure: unknown figure name(s) {sorted(missing)}")
569
+
570
+ tmp: list[str] = []
571
+ try:
572
+ sections_file = _sections_file(spec, tmp)
573
+ section_select = _resolve_section_select(args.select, sections_file)
574
+ if out_dir is None and out_pdf is None and sections.can_display():
575
+ # No output destination + a display available: show on screen,
576
+ # mirroring the scalar/profiles subcommands. Headless/CI (no tty or a
577
+ # non-GUI backend) falls through to a PNG tree in the cwd below.
578
+ result = _render_show(
579
+ figures, source, sections_file, args, wanted, default_dpi,
580
+ section_select,
581
+ )
582
+ elif out_pdf is not None:
583
+ result = _render_pdf(
584
+ figures, source, sections_file, args, wanted, default_dpi, out_pdf,
585
+ section_select,
586
+ )
587
+ else:
588
+ output_dir = Path(out_dir or ".").expanduser()
589
+ rendered = 0
590
+ for _name, mod, fig_args in _compiled_figures(
591
+ figures, source, sections_file, args, wanted, default_dpi,
592
+ output_dir, section_select, make_output=True,
593
+ ):
594
+ mod.run(fig_args)
595
+ rendered += 1
596
+ print(f"Rendered {rendered} figure(s) into {output_dir}")
597
+ result = str(output_dir)
598
+ finally:
599
+ for t in tmp:
600
+ with contextlib.suppress(OSError):
601
+ os.unlink(t)
602
+ return result
603
+
604
+
605
+ def _render_show(figures, source, sections_file, args, wanted, default_dpi,
606
+ section_select=None) -> str:
607
+ """Display the selected figures on screen (blocking), writing no files.
608
+
609
+ Streams each figure's ``build_figures`` (``make_output=False``, so no PNG
610
+ subdirs) and hands the open figures to ``sections.save_or_show`` with no
611
+ output dir, which holds them all open and calls ``plt.show()``. ``run`` only
612
+ routes here when ``sections.can_display()`` is true, so an interactive
613
+ backend is assumed. Note a broad spec (many figures x many sections) opens
614
+ that many windows at once.
615
+ """
616
+ def _stream():
617
+ for _name, mod, fig_args in _compiled_figures(
618
+ figures, source, sections_file, args, wanted, default_dpi,
619
+ Path("."), section_select, make_output=False,
620
+ ):
621
+ # closing_figs releases each preset's dataset handle; close_new_figs
622
+ # closes a figure orphaned by a build error before it was yielded.
623
+ with sections.closing_figs(mod.build_figures(fig_args)) as gen, \
624
+ sections.close_new_figs_on_error():
625
+ yield from gen
626
+
627
+ shown = sections.save_or_show(_stream(), None, default_dpi or 150)
628
+ return f"displayed {shown} figure(s)"
629
+
630
+
631
+ def _render_pdf(figures, source, sections_file, args, wanted, default_dpi,
632
+ out_pdf: str, section_select=None) -> str:
633
+ """Render every selected figure into one multipage PDF (one page per figure
634
+ the preset produces).
635
+
636
+ Written to a temp file and ``os.replace``-d into place only on FULL success,
637
+ so a failure in a later figure never leaves a partial PDF that looks
638
+ complete but is silently missing pages; an all-empty spec raises and leaves
639
+ nothing. Figures stream one at a time and are closed as written.
640
+ """
641
+ import matplotlib.pyplot as plt
642
+ from matplotlib.backends.backend_pdf import PdfPages
643
+
644
+ pdf_path = Path(out_pdf).expanduser()
645
+ pdf_path.parent.mkdir(parents=True, exist_ok=True)
646
+ fd, tmp_name = tempfile.mkstemp(suffix=".pdf", prefix=".figspec_",
647
+ dir=str(pdf_path.parent))
648
+ os.close(fd)
649
+ pages = 0
650
+ try:
651
+ with PdfPages(tmp_name) as pdf:
652
+ for _name, mod, fig_args in _compiled_figures(
653
+ figures, source, sections_file, args, wanted, default_dpi,
654
+ Path("."), section_select, make_output=False,
655
+ ):
656
+ # closing_figs releases the preset's dataset handle even if
657
+ # savefig raises mid-stream; close_new_figs_on_error closes a
658
+ # figure orphaned by a build error before it was yielded (e.g.
659
+ # eps-chi, which builds its figure inline).
660
+ with sections.closing_figs(mod.build_figures(fig_args)) as gen, \
661
+ sections.close_new_figs_on_error():
662
+ for _stem, fig in gen:
663
+ try:
664
+ pdf.savefig(fig, dpi=fig_args.dpi or 150)
665
+ pages += 1
666
+ finally:
667
+ plt.close(fig)
668
+ if pages == 0:
669
+ raise SpecError(
670
+ "figure spec: no figures were produced — "
671
+ "nothing to write to the PDF"
672
+ )
673
+ os.replace(tmp_name, str(pdf_path)) # atomic; only after full success
674
+ except BaseException:
675
+ with contextlib.suppress(OSError):
676
+ os.unlink(tmp_name)
677
+ raise
678
+ print(f"Wrote {pages} page(s) to {pdf_path}")
679
+ return str(pdf_path)