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,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)
|