tecio-python 0.2.0__py3-none-any.whl → 0.2.2__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.
tecio/cli/tecaux.py ADDED
@@ -0,0 +1,971 @@
1
+ r"""Add, remove, or export dataset, zone, or variable level auxiliary data.
2
+
3
+ Tecplot's auxiliary data mechanism attaches arbitrary ``name=value`` metadata to a
4
+ dataset, a zone, or a variable (solver name, run date, units, a description, or any
5
+ other annotation that doesn't belong in the numerical data itself_). Managing this
6
+ after the fact ordinarily means writing a one-off script against the TecIO API.
7
+ ``tecaux`` does this from the command line, in a single read/write pass, in three
8
+ mutually exclusive modes:
9
+
10
+ * By default (no ``--strip``/``--export-json``): merges new auxiliary entries (from
11
+ ``-d``/``-z``/``-v`` and/or ``-j``) into a copy of the input file. Every zone,
12
+ variable, and existing sharing relationship is preserved exactly; an existing key with
13
+ the same name is overwritten.
14
+ * ``--strip`` removes every auxiliary entry at all three levels and writes the result to
15
+ a new file, still preserving sharing.
16
+ * ``--export-json`` writes every *existing* auxiliary entry to a JSON file, in the same
17
+ format ``-j`` reads back -- without touching the source file at all, unless combined
18
+ with ``--strip``.
19
+
20
+ :Usage:
21
+
22
+ .. code:: bash
23
+
24
+ tecaux [-h] [-d KEY=VALUE] ... [-z INDEX KEY=VALUE] ...
25
+ [-v INDEX_OR_NAME KEY=VALUE] ... [-j PATH] [-s] [--export-json]
26
+ [-o PATH] [-f] PATH
27
+
28
+ :Positional Arguments:
29
+ ``PATH``
30
+ Path to the input Tecplot file (``.plt``, ``.szplt``, or ``.dat``).
31
+
32
+ :Options:
33
+ ``-d KEY=VALUE``, ``--data KEY=VALUE``
34
+ A ``name=value`` pair to set as dataset-level auxiliary data. Repeat the flag
35
+ for multiple pairs.
36
+
37
+ ``-z INDEX KEY=VALUE``, ``--zone INDEX KEY=VALUE``
38
+ A ``name=value`` pair to set as zone-level auxiliary data on the one-based zone
39
+ ``INDEX`` -- or on every zone if ``INDEX`` is the literal word ``all``. Repeat
40
+ the flag for multiple pairs and/or multiple zones; each occurrence takes exactly
41
+ one zone and one pair, so ``-z 1 A=1 -z 1 B=2`` sets both ``A`` and ``B`` on
42
+ zone 1.
43
+
44
+ ``-v INDEX_OR_NAME KEY=VALUE``, ``--var INDEX_OR_NAME KEY=VALUE``
45
+ A ``name=value`` pair to set as variable-level auxiliary data on the variable
46
+ given by a one-based index or a name (case-insensitive) -- or on every variable
47
+ if the target is the literal word ``all``. Repeatable, same as ``-z``.
48
+
49
+ ``-j PATH``, ``--json PATH``
50
+ Load bulk auxiliary data from a JSON file (see format below). Applied before
51
+ any ``-d``/``-z``/``-v`` flags, which take precedence on a key collision.
52
+
53
+ ``-s``, ``--strip``
54
+ Remove all auxiliary data (dataset, zone, and variable levels) and write the
55
+ result to ``<stem>_no_aux<ext>`` (or ``-o``'s path). Mutually exclusive with
56
+ ``-d``/``-z``/``-v``/``-j``. Combine with ``--export-json`` to keep a JSON copy
57
+ of what was removed.
58
+
59
+ ``--export-json``
60
+ Write every existing auxiliary entry to ``<stem>_aux.json``, in the same
61
+ ``AUXDATASET``/``AUXZONE``/``AUXVAR`` format ``-j`` reads, without modifying
62
+ the source file, unless combined with ``--strip``. Mutually exclusive with
63
+ ``-d``/``-z``/``-v``/``-j``.
64
+
65
+ ``-o PATH``, ``--output PATH``
66
+ Output file path. The extension controls the output format: ``.szplt``,
67
+ ``.plt``, or ``.dat``. Defaults to ``<stem>_aux<ext>``, or
68
+ ``<stem>_no_aux<ext>`` with ``--strip``. Not used by ``--export-json`` alone
69
+ which always writes ``<stem>_aux.json``, regardless of ``-o``.
70
+
71
+ ``-f``, ``--force``
72
+ Overwrite the output file(s) if they already exist. Without this flag the
73
+ command exits with an error rather than silently clobbering an existing file.
74
+
75
+ :JSON Format:
76
+ .. code:: json
77
+
78
+ {
79
+ "AUXDATASET": {"Solver": "MyCFD", "Version": "2.1"},
80
+ "AUXZONE": {
81
+ "1": {"Description": "Wing"},
82
+ "all": {"Batch": "2024"}
83
+ },
84
+ "AUXVAR": {
85
+ "Pressure": {"Units": "Pa"},
86
+ "1": {"Source": "Experiment"}
87
+ }
88
+ }
89
+
90
+ All three top-level keys are optional, and match this library's own attribute/method
91
+ names for the same three levels exactly
92
+ (``Write.auxdataset``/``add_auxdataset_dict``, ``Write.auxvar``/``add_auxvar_dict``;
93
+ ``AUXZONE`` is the natural third member of that family even though zone-level aux
94
+ has no "zone"-prefixed name internally.
95
+
96
+ In ``"AUXZONE"``/``"AUXVAR"``, a key is either a one-based index, a variable name
97
+ (``"AUXVAR"`` only), or the literal string ``"all"`` meaning every zone/variable
98
+ (the same three forms ``-z``/``-v`` accept on the command line). Every JSON key must
99
+ be a quoted string, including numeric indices (``"1"``, not ``1``).
100
+
101
+ ``--export-json`` writes this exact format back out, keyed by exact 1-based index
102
+ (never ``"all"``, even if every zone happens to share identical aux content) and
103
+ omitting any zone/variable/level with nothing to report.
104
+
105
+ :Returns:
106
+ In the default mode, a new Tecplot file written to the output path with the
107
+ requested auxiliary data merged in. With ``--strip``, a new Tecplot file with all
108
+ auxiliary data removed. With ``--export-json``, a JSON file of everything that was
109
+ found (and the source file is untouched, unless ``--strip`` is also given). Exit
110
+ code is ``0`` on success and non-zero if the input file cannot be read, a
111
+ ``-z``/``-v``/JSON target cannot be resolved, ``--strip``/``--export-json`` is
112
+ combined with ``-d``/``-z``/``-v``/``-j``, or an output file already exists and
113
+ ``--force`` is not set.
114
+
115
+ Examples:
116
+ Tag a dataset with solver metadata (repeat the flag for multiple pairs)::
117
+
118
+ $ tecaux -d Solver=MyCFD -d Version=2.1 flow.szplt
119
+
120
+ Two pairs on zone 1, one pair on zone 2::
121
+
122
+ $ tecaux -z 1 Description=Wing -z 1 Area=120sqm -z 2 Description=Fuselage \
123
+ flow.szplt
124
+
125
+ Annotate a single variable by name::
126
+
127
+ $ tecaux -v Pressure Units=Pa flow.szplt
128
+
129
+ Every zone at once, via the "all" target::
130
+
131
+ $ tecaux -z all RunDate=2024-01-15 flow.plt
132
+
133
+ Everything in one pass, written as a bash script would naturally lay it out::
134
+
135
+ $ tecaux --data Solver=MyCFD \
136
+ --data Version=2.1 \
137
+ --zone 1 Case=A \
138
+ --zone 1 Description=Wing \
139
+ --zone 2 Case=B \
140
+ --var Pressure Units=Pa \
141
+ -o tagged.szplt flow.szplt
142
+
143
+ Bulk metadata from a file, with one CLI override on top::
144
+
145
+ $ tecaux -j metadata.json -d Version=2.2 flow.szplt
146
+
147
+ Back up everything currently on a file before editing it by hand, without touching
148
+ the file itself::
149
+
150
+ $ tecaux --export-json flow.szplt # writes flow_aux.json
151
+
152
+ Sanitize a file for external sharing, discarding whatever aux data it had::
153
+
154
+ $ tecaux --strip flow.szplt # writes flow_no_aux.szplt
155
+
156
+ Sanitize a file but keep a record of what was removed, in case it's needed later
157
+ (e.g. to reapply with ``-j`` after review)::
158
+
159
+ $ tecaux --strip --export-json flow.szplt
160
+ # writes flow_no_aux.szplt and flow_aux.json
161
+
162
+ Call directly from a Python session::
163
+
164
+ import tecio.cli.tecaux.main as tecaux
165
+
166
+ tecaux(["-d", "Solver=MyCFD", "flow.szplt"])
167
+
168
+ See Also:
169
+ * :mod:`tecio.cli.tecfix` - Rewrite a file with invalid variable arrays set to
170
+ passive, using the same verbatim zone-copy approach.
171
+ * :mod:`tecio.cli.teconvert` - Convert between formats without modifying auxiliary
172
+ data.
173
+
174
+ """
175
+
176
+ from __future__ import annotations
177
+
178
+ import argparse
179
+ import json
180
+ import sys
181
+ from collections.abc import Sequence
182
+ from pathlib import Path
183
+ from typing import Any
184
+
185
+ import numpy as np
186
+
187
+ from .. import open as tecio_open
188
+ from ..libtecio import ZoneType
189
+
190
+ # --------------------------------------------------------------------------------------
191
+ # Constants
192
+ # --------------------------------------------------------------------------------------
193
+
194
+ #: FE zone types that the Write API cannot copy.
195
+ _FE_POLY: frozenset[ZoneType] = frozenset({ZoneType.FEPOLYGON, ZoneType.FEPOLYHEDRON})
196
+
197
+
198
+ class _ArgError(Exception):
199
+ """Raised for a malformed KEY=VALUE pair or unresolvable target."""
200
+
201
+
202
+ # --------------------------------------------------------------------------------------
203
+ # Argument parsing
204
+ # --------------------------------------------------------------------------------------
205
+
206
+
207
+ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
208
+ parser = argparse.ArgumentParser(
209
+ prog="tecaux",
210
+ description=(
211
+ # -|-------------------|---------------------------------------------|
212
+ "Add dataset-, zone-, or variable-level auxiliary data to a Tecplot\n"
213
+ "file in a single read/write pass. Everything else (zones, variables,\n"
214
+ "and sharing) is copied verbatim."
215
+ ),
216
+ epilog=(
217
+ # -|-------------------|---------------------------------------------|
218
+ "Example usage:\n"
219
+ " Dataset-level metadata (repeat the flag for multiple pairs)\n"
220
+ " $ tecaux -d Solver=MyCFD -d Version=2.1 <file>\n"
221
+ " Two pairs on zone 1, one pair on zone 2\n"
222
+ " $ tecaux -z 1 Description=Wing -z 1 Area=120sqm -z 2 Case=B <file>\n"
223
+ " A single variable by name\n"
224
+ " $ tecaux -v Pressure Units=Pa <file>\n"
225
+ " Every zone at once\n"
226
+ " $ tecaux -z all RunDate=2024-01-15 <file>\n"
227
+ " Bulk metadata from a file\n"
228
+ " $ tecaux -j metadata.json <file>\n"
229
+ " Export existing aux data without touching the source file\n"
230
+ " $ tecaux --export-json <file>\n"
231
+ " Strip all aux data, keeping a JSON backup of what was removed\n"
232
+ " $ tecaux --strip --export-json <file>\n"
233
+ ),
234
+ formatter_class=lambda prog: argparse.RawDescriptionHelpFormatter(
235
+ prog, width=70, max_help_position=24
236
+ ),
237
+ )
238
+ parser.add_argument(
239
+ "filename",
240
+ type=str,
241
+ help="Input Tecplot file to add auxiliary data to.",
242
+ )
243
+ parser.add_argument(
244
+ "-d",
245
+ "--data",
246
+ action="append",
247
+ default=None,
248
+ metavar="KEY=VALUE",
249
+ help="A name=value pair to set as dataset-level auxiliary data. Repeatable.",
250
+ )
251
+ parser.add_argument(
252
+ "-z",
253
+ "--zone",
254
+ action="append",
255
+ nargs=2,
256
+ default=None,
257
+ metavar=("INDEX", "KEY=VALUE"),
258
+ help=(
259
+ "A name=value pair to set as zone-level auxiliary data on the "
260
+ "given one-based zone index, or on every zone if INDEX is "
261
+ "'all'. Repeatable, one pair per occurrence."
262
+ ),
263
+ )
264
+ parser.add_argument(
265
+ "-v",
266
+ "--var",
267
+ action="append",
268
+ nargs=2,
269
+ default=None,
270
+ metavar=("INDEX_OR_NAME", "KEY=VALUE"),
271
+ help=(
272
+ "A name=value pair to set as variable-level auxiliary data on "
273
+ "the given variable (1-based index or name), or on every "
274
+ "variable if the target is 'all'. Repeatable, one pair per "
275
+ "occurrence."
276
+ ),
277
+ )
278
+ parser.add_argument(
279
+ "-j",
280
+ "--json",
281
+ type=str,
282
+ default=None,
283
+ metavar="PATH",
284
+ help=(
285
+ "Load bulk auxiliary data from a JSON file, applied before "
286
+ "-d/-z/-v (which override it on a key collision). Top-level "
287
+ "keys: AUXDATASET, AUXZONE, AUXVAR (matching Write.auxdataset/"
288
+ "Write.auxvar). Full format in the module docstring."
289
+ ),
290
+ )
291
+ parser.add_argument(
292
+ "-s",
293
+ "--strip",
294
+ action="store_true",
295
+ default=False,
296
+ help=(
297
+ "Remove all auxiliary data (dataset, zone, and variable levels) "
298
+ "and write the result to <stem>_no_aux<ext> (or -o's path). "
299
+ "Mutually exclusive with -d/-z/-v/-j; combine with "
300
+ "--export-json to also save what was removed."
301
+ ),
302
+ )
303
+ parser.add_argument(
304
+ "--export-json",
305
+ action="store_true",
306
+ default=False,
307
+ help=(
308
+ "Write every existing auxiliary entry to <stem>_aux.json, in "
309
+ "the same AUXDATASET/AUXZONE/AUXVAR format -j reads -- without "
310
+ "modifying the source file, unless combined with --strip. "
311
+ "Mutually exclusive with -d/-z/-v/-j."
312
+ ),
313
+ )
314
+ parser.add_argument(
315
+ "-o",
316
+ "--output",
317
+ type=str,
318
+ default=None,
319
+ metavar="PATH",
320
+ help=(
321
+ "Output file path. The extension controls the output format. "
322
+ "Defaults to <stem>_aux<ext> in the same directory as the input "
323
+ "(or <stem>_no_aux<ext> with --strip; not used by --export-json "
324
+ "alone, which always writes <stem>_aux.json)."
325
+ ),
326
+ )
327
+ parser.add_argument(
328
+ "-f",
329
+ "--force",
330
+ action="store_true",
331
+ default=False,
332
+ help="Overwrite the output file if it already exists.",
333
+ )
334
+ return parser.parse_args(argv)
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Helpers
339
+ # ---------------------------------------------------------------------------
340
+
341
+
342
+ def _parse_kv(token: str) -> tuple[str, str]:
343
+ """Parse one ``KEY=VALUE`` token.
344
+
345
+ Args:
346
+ token: A single string like ``"Solver=MyCFD"``. Only the first ``"="`` is
347
+ significant, so a value may itself contain ``"="``
348
+ (e.g. ``"Formula=a=b+c"``). A value containing spaces must be quoted as
349
+ a whole on the command line, the same as any other shell argument
350
+ (e.g. ``-z 1 "Description=Wing surface"``) -- this function only ever
351
+ sees whatever single token the shell already produced, so it can't
352
+ recover a value that arrived pre-split into several tokens.
353
+
354
+ Returns:
355
+ ``(key, value)``, with the key stripped of surrounding whitespace.
356
+
357
+ Raises:
358
+ _ArgError: If *token* has no ``"="``, or the key is empty.
359
+
360
+ """
361
+ if "=" not in token:
362
+ raise _ArgError(f"Expected KEY=VALUE, got: {token!r}")
363
+ key, _, value = token.partition("=")
364
+ key = key.strip()
365
+ if not key:
366
+ raise _ArgError(f"Empty key in: {token!r}")
367
+ return key, value
368
+
369
+
370
+ def _resolve_variable(spec: str, var_names: list[str]) -> int | None:
371
+ """Return a 0-based variable index from a name or 1-based integer string.
372
+
373
+ Args:
374
+ spec: User-supplied string (e.g. ``"3"`` or ``"Pressure"``).
375
+ var_names: Ordered list of variable names from the reader.
376
+
377
+ Returns:
378
+ 0-based variable index, or ``None`` if *spec* cannot be resolved. Callers are
379
+ responsible for reporting the failure.
380
+
381
+ """
382
+ try:
383
+ idx = int(spec)
384
+ except ValueError:
385
+ pass
386
+ else:
387
+ if idx < 1 or idx > len(var_names):
388
+ return None
389
+ return idx - 1
390
+
391
+ spec_lower = spec.lower()
392
+ for i, name in enumerate(var_names):
393
+ if name.lower() == spec_lower:
394
+ return i
395
+
396
+ return None
397
+
398
+
399
+ def _to_groups(
400
+ raw_pairs: list[list[str]] | None,
401
+ ) -> list[tuple[str | None, dict[str, str]]]:
402
+ """Convert argparse's ``[[target, "KEY=VALUE"], ...]`` into consolidation input.
403
+
404
+ Each occurrence becomes its own single-pair group; ``_consolidate_groups`` merges
405
+ repeats of the same target on its own, so nothing needs to be pre-grouped here.
406
+
407
+ Args:
408
+ raw_pairs: ``args.zone``/``args.var`` as argparse produced them, or ``None`` if
409
+ the flag was never given.
410
+
411
+ Returns:
412
+ ``[(target, {key: value}), ...]``, target ``None`` for ``"all"``.
413
+
414
+ Raises:
415
+ _ArgError: If a KEY=VALUE token is malformed.
416
+
417
+ """
418
+ groups: list[tuple[str | None, dict[str, str]]] = []
419
+ for target_raw, kv_raw in raw_pairs or []:
420
+ target = None if target_raw.lower() == "all" else target_raw
421
+ key, value = _parse_kv(kv_raw)
422
+ groups.append((target, {key: value}))
423
+ return groups
424
+
425
+
426
+ def _consolidate_groups(
427
+ groups: list[tuple[str | None, dict[str, str]]],
428
+ resolve: Any,
429
+ ) -> tuple[dict[str, str], dict[int, dict[str, str]]]:
430
+ """Merge repeated groups into one broadcast dict and one per-target dict.
431
+
432
+ Args:
433
+ groups: ``[(target, {key: value}), ...]`` -- target is ``None`` ("every
434
+ zone"/"every variable") or a raw string to resolve.
435
+ resolve: Callable taking the raw target string and returning a 1-based index for
436
+ a specific target, or raising ``_ArgError`` if it can't be resolved.
437
+
438
+ Returns:
439
+ ``(broadcast, by_target)`` where ``by_target`` is keyed by whatever index
440
+ ``resolve`` returns.
441
+
442
+ """
443
+ broadcast: dict[str, str] = {}
444
+ by_target: dict[int, dict[str, str]] = {}
445
+ for target, pairs in groups:
446
+ if target is None:
447
+ broadcast.update(pairs)
448
+ else:
449
+ idx = resolve(target)
450
+ by_target.setdefault(idx, {}).update(pairs)
451
+ return broadcast, by_target
452
+
453
+
454
+ # --------------------------------------------------------------------------------------
455
+ # JSON loading
456
+ # --------------------------------------------------------------------------------------
457
+
458
+
459
+ def _load_json_aux(
460
+ path: str,
461
+ ) -> tuple[
462
+ dict[str, str],
463
+ list[tuple[str | None, dict[str, str]]],
464
+ list[tuple[str | None, dict[str, str]]],
465
+ ]:
466
+ """Load bulk auxiliary data from a JSON file.
467
+
468
+ Top-level keys are ``AUXDATASET``, ``AUXZONE``, ``AUXVAR``.
469
+
470
+ Returns:
471
+ ``(dataset_aux, zone_groups, var_groups)`` in the same shape ``_to_groups``
472
+ produces, so both sources merge identically.
473
+
474
+ Raises:
475
+ _ArgError: If the file can't be read or parsed, contains an unrecognized
476
+ top-level key, or a value isn't a string -> string mapping.
477
+
478
+ """
479
+ try:
480
+ with open(path, encoding="utf-8") as fh:
481
+ data = json.load(fh)
482
+ except (OSError, json.JSONDecodeError) as exc:
483
+ raise _ArgError(f"Could not read JSON file {path!r}: {exc}") from exc
484
+
485
+ if not isinstance(data, dict):
486
+ raise _ArgError(f"JSON file {path!r} must contain an object at the top level.")
487
+
488
+ _KNOWN_KEYS = {"AUXDATASET", "AUXZONE", "AUXVAR"}
489
+ unknown: set[str] = {str(k) for k in data} - _KNOWN_KEYS
490
+ if unknown:
491
+ raise _ArgError(
492
+ f"Unrecognized top-level key(s) in {path!r}: {sorted(unknown)}. "
493
+ f"Expected one of: {sorted(_KNOWN_KEYS)}."
494
+ )
495
+
496
+ def _as_str_dict(obj: Any, where: str) -> dict[str, str]:
497
+ if not isinstance(obj, dict):
498
+ raise _ArgError(f"{where} must be an object of name: value strings.")
499
+ return {str(k): str(v) for k, v in obj.items()}
500
+
501
+ dataset_aux = _as_str_dict(data.get("AUXDATASET", {}), "'AUXDATASET'")
502
+
503
+ zone_groups: list[tuple[str | None, dict[str, str]]] = []
504
+ for key, val in data.get("AUXZONE", {}).items():
505
+ target = None if str(key).lower() == "all" else str(key)
506
+ zone_groups.append((target, _as_str_dict(val, f"'AUXZONE.{key}'")))
507
+
508
+ var_groups: list[tuple[str | None, dict[str, str]]] = []
509
+ for key, val in data.get("AUXVAR", {}).items():
510
+ target = None if str(key).lower() == "all" else str(key)
511
+ var_groups.append((target, _as_str_dict(val, f"'AUXVAR.{key}'")))
512
+
513
+ return dataset_aux, zone_groups, var_groups
514
+
515
+
516
+ def _collect_all_aux(reader: Any) -> dict[str, Any]:
517
+ """Collect every dataset-, zone-, and variable-level aux entry from *reader*.
518
+
519
+ The exact inverse of ``_load_json_aux``: produces the same
520
+ ``AUXDATASET``/``AUXZONE``/``AUXVAR`` structure, so a file exported with
521
+ ``--export-json`` can be fed straight back in with ``-j`` (to the same
522
+ file, a modified copy, or an entirely different one) without any
523
+ reshaping. Zones/variables are keyed by their exact 1-based index --
524
+ never collapsed into an ``"all"`` entry even when every zone happens to
525
+ share the same aux content, since a later zone added to the file
526
+ wouldn't have had that entry originally and shouldn't silently inherit
527
+ it on a subsequent import.
528
+
529
+ A zone or variable with no aux entries at all is omitted entirely
530
+ (sparse), not written as an empty object -- matching how a missing key
531
+ on the *input* side already means "nothing to apply here".
532
+
533
+ Args:
534
+ reader: An open ``Read`` instance.
535
+
536
+ Returns:
537
+ A dict with up to three top-level keys (``AUXDATASET``, ``AUXZONE``,
538
+ ``AUXVAR``); a level with nothing to export is omitted entirely, so
539
+ a file with no aux data anywhere yields ``{}``.
540
+
541
+ """
542
+ result: dict[str, Any] = {}
543
+
544
+ dataset_aux = dict(reader.auxdata.items())
545
+ if dataset_aux:
546
+ result["AUXDATASET"] = dataset_aux
547
+
548
+ zone_aux: dict[str, dict[str, str]] = {}
549
+ for i, zone in enumerate(reader.zone):
550
+ entries = dict(zone.auxdata.items())
551
+ if entries:
552
+ zone_aux[str(i + 1)] = entries
553
+ if zone_aux:
554
+ result["AUXZONE"] = zone_aux
555
+
556
+ var_aux: dict[str, dict[str, str]] = {}
557
+ for i in range(reader.num_vars):
558
+ entries = dict(reader.get_var_auxdata(i + 1).items())
559
+ if entries:
560
+ var_aux[str(i + 1)] = entries
561
+ if var_aux:
562
+ result["AUXVAR"] = var_aux
563
+
564
+ return result
565
+
566
+
567
+ # --------------------------------------------------------------------------------------
568
+ # Per-zone processing
569
+ # --------------------------------------------------------------------------------------
570
+
571
+
572
+ def _process_zone(
573
+ zone: Any,
574
+ ) -> tuple[list[np.ndarray], list[Any], list[bool], list[int], dict[str, str]]:
575
+ """Copy one zone's variable data/metadata verbatim.
576
+
577
+ Sharing references are passed through unchanged: this tool writes every zone in the
578
+ same order as the source, so source zone N is always output zone N.
579
+
580
+ Returns:
581
+ A 5-tuple: ``(writer_data, writer_locs, passive_vars, var_sharing,
582
+ existing_aux)``, where the first two are filtered to active, non-shared
583
+ variables only.
584
+ """
585
+ active_data: list[np.ndarray] = []
586
+ active_locs: list[Any] = []
587
+ passive_vars: list[bool] = []
588
+ var_sharing: list[int] = []
589
+
590
+ for var in zone.variable:
591
+ is_passive = var.is_passive()
592
+ sv = var.shared_zone # 1-based source zone index, or None
593
+ share_int = sv if sv is not None else 0
594
+
595
+ passive_vars.append(is_passive)
596
+ var_sharing.append(share_int)
597
+ active_locs.append(var.value_location)
598
+
599
+ if is_passive or share_int != 0:
600
+ active_data.append(np.array([], dtype=np.float32))
601
+ continue
602
+
603
+ arr = var.values
604
+ if arr is None or arr.size == 0:
605
+ passive_vars[-1] = True
606
+ active_data.append(np.array([], dtype=np.float32))
607
+ else:
608
+ active_data.append(arr)
609
+
610
+ writer_data = [
611
+ arr
612
+ for arr, is_p, sv in zip(active_data, passive_vars, var_sharing, strict=False)
613
+ if not is_p and sv == 0
614
+ ]
615
+ writer_locs = [
616
+ loc
617
+ for loc, is_p, sv in zip(active_locs, passive_vars, var_sharing, strict=False)
618
+ if not is_p and sv == 0
619
+ ]
620
+
621
+ existing_aux: dict[str, str] = {}
622
+ if len(zone.auxdata) > 0:
623
+ existing_aux = dict(zone.auxdata.items())
624
+
625
+ return writer_data, writer_locs, passive_vars, var_sharing, existing_aux
626
+
627
+
628
+ def _write_zone_data(
629
+ writer: Any,
630
+ zone: Any,
631
+ writer_data: list[np.ndarray],
632
+ writer_locs: list[Any],
633
+ passive_vars: list[bool],
634
+ var_sharing: list[int],
635
+ zone_aux: dict[str, str] | None,
636
+ ) -> None:
637
+ """Perform the actual write zone call for one already processed zone.
638
+
639
+ Shared between add-mode (``main()``) and strip/export-mode
640
+ (``_run_strip_or_export()``) -- the only thing that differs between them
641
+ is what *zone_aux* is (a merged dict, or ``None`` when stripping), so
642
+ this is the one piece worth keeping in exactly one place rather than
643
+ risking the ``con_sharing``/``node_map`` handling drifting between two
644
+ copies.
645
+
646
+ Args:
647
+ writer: Open ``Write`` instance for the destination file.
648
+ zone: Source ``ReadZone`` being copied.
649
+ writer_data: Active, non-shared variable arrays (from ``_process_zone``).
650
+ writer_locs: Matching value locations (from ``_process_zone``).
651
+ passive_vars: Per-variable passive flags, dataset order.
652
+ var_sharing: Per-variable sharing, dataset order.
653
+ zone_aux: Zone-level aux to write, or ``None`` for none at all.
654
+
655
+ """
656
+ zt = zone.zone_type
657
+ common_kw: dict[str, Any] = dict(
658
+ title=zone.title,
659
+ value_locations=writer_locs,
660
+ passive_vars=passive_vars,
661
+ var_sharing=var_sharing,
662
+ solution_time=zone.solution_time,
663
+ strand_id=zone.strand_id,
664
+ aux=zone_aux,
665
+ )
666
+
667
+ if zt == ZoneType.ORDERED:
668
+ writer.write_ijk_zone(data=writer_data, **common_kw)
669
+ else:
670
+ con_sharing = zone.shared_connectivity
671
+ writer.write_fe_zone(
672
+ zone_type=zt,
673
+ data=writer_data,
674
+ node_map=None if con_sharing else zone.node_map,
675
+ con_sharing=con_sharing,
676
+ **common_kw,
677
+ )
678
+
679
+
680
+ # --------------------------------------------------------------------------------------
681
+ # Main entry point
682
+ # --------------------------------------------------------------------------------------
683
+
684
+
685
+ def _run_strip_or_export(args: argparse.Namespace, src: Path) -> int:
686
+ """Handle ``--strip``/``--export-json``, independently or together.
687
+
688
+ Args:
689
+ args: Parsed arguments; ``args.strip`` and/or ``args.export_json``
690
+ is ``True`` (``main()`` only calls this when at least one is).
691
+ src: Validated-to-exist input file path.
692
+
693
+ Returns:
694
+ Exit code -- ``0`` on success, ``1`` on error.
695
+
696
+ """
697
+ if args.output is not None and args.export_json and not args.strip:
698
+ print(
699
+ "Warning: -o is ignored by --export-json alone -- the export "
700
+ f"always goes to {src.stem}_aux.json.",
701
+ file=sys.stderr,
702
+ )
703
+
704
+ json_dst = src.with_name(f"{src.stem}_aux.json")
705
+ strip_dst = (
706
+ Path(args.output)
707
+ if args.output is not None
708
+ else src.with_stem(src.stem + "_no_aux")
709
+ )
710
+
711
+ if args.export_json and json_dst.exists() and not args.force:
712
+ print(
713
+ f"Error: output file already exists: {json_dst}\nUse --force to overwrite.",
714
+ file=sys.stderr,
715
+ )
716
+ return 1
717
+ if args.strip and strip_dst.exists() and not args.force:
718
+ print(
719
+ (
720
+ f"Error: output file already exists: {strip_dst}\n"
721
+ "Use --force to overwrite."
722
+ ),
723
+ file=sys.stderr,
724
+ )
725
+ return 1
726
+
727
+ try:
728
+ with tecio_open(str(src), "r") as reader:
729
+ if args.export_json:
730
+ aux = _collect_all_aux(reader)
731
+ with open(json_dst, "w", encoding="utf-8") as fh:
732
+ json.dump(aux, fh, indent=2)
733
+ print(f"Exported auxiliary data: {src} -> {json_dst}")
734
+
735
+ if args.strip:
736
+ print(f"Stripping auxiliary data: {src} -> {strip_dst}")
737
+ with tecio_open(
738
+ str(strip_dst),
739
+ "w",
740
+ title=reader.title,
741
+ variables=reader.variables,
742
+ file_type=reader.file_type,
743
+ ) as writer:
744
+ for i, zone in enumerate(reader.zone):
745
+ zt = zone.zone_type
746
+ if zt in _FE_POLY:
747
+ print(
748
+ f"Warning: zone {i + 1} ('{zone.title}') is "
749
+ f"{zt.name} and cannot be copied -- skipping.",
750
+ file=sys.stderr,
751
+ )
752
+ continue
753
+
754
+ writer_data, writer_locs, passive_vars, var_sharing, _ = (
755
+ _process_zone(zone)
756
+ )
757
+ # No dataset/var aux is ever added to the writer, and zone_aux
758
+ # is unconditionally None here.
759
+ _write_zone_data(
760
+ writer,
761
+ zone,
762
+ writer_data,
763
+ writer_locs,
764
+ passive_vars,
765
+ var_sharing,
766
+ None,
767
+ )
768
+ print(f"Done. Stripped output written to: {strip_dst}")
769
+
770
+ except Exception as exc: # noqa: BLE001
771
+ print(f"Error: {exc}", file=sys.stderr)
772
+ if args.strip:
773
+ strip_dst.unlink(missing_ok=True)
774
+ if args.export_json:
775
+ json_dst.unlink(missing_ok=True)
776
+ return 1
777
+
778
+ return 0
779
+
780
+
781
+ def main(argv: Sequence[str] | None = None) -> int:
782
+ """Add, remove, or export auxiliary data to a Tecplot file.
783
+
784
+ Returns:
785
+ Exit code -- ``0`` on success, ``1`` on error.
786
+
787
+ """
788
+ args = _parse_args(argv)
789
+
790
+ src = Path(args.filename)
791
+ if not src.exists():
792
+ print(f"Error: input file not found: {src}", file=sys.stderr)
793
+ return 1
794
+
795
+ if args.strip or args.export_json:
796
+ add_flags_given = bool(args.data or args.zone or args.var or args.json)
797
+ if add_flags_given:
798
+ print(
799
+ "Error: --strip/--export-json cannot be combined with "
800
+ "-d/-z/-v/-j -- run them as separate calls.",
801
+ file=sys.stderr,
802
+ )
803
+ return 1
804
+ return _run_strip_or_export(args, src)
805
+
806
+ dst = (
807
+ Path(args.output)
808
+ if args.output is not None
809
+ else src.with_stem(src.stem + "_aux")
810
+ )
811
+
812
+ if dst.exists() and not args.force:
813
+ print(
814
+ f"Error: output file already exists: {dst}\nUse --force to overwrite.",
815
+ file=sys.stderr,
816
+ )
817
+ return 1
818
+
819
+ try:
820
+ # JSON is the baseline; -d/-z/-v groups are appended after it, so they naturally
821
+ # win on a key collision.
822
+ dataset_aux: dict[str, str] = {}
823
+ zone_groups: list[tuple[str | None, dict[str, str]]] = []
824
+ var_groups: list[tuple[str | None, dict[str, str]]] = []
825
+
826
+ if args.json is not None:
827
+ j_dataset, j_zones, j_vars = _load_json_aux(args.json)
828
+ dataset_aux.update(j_dataset)
829
+ zone_groups.extend(j_zones)
830
+ var_groups.extend(j_vars)
831
+
832
+ for kv in args.data or []:
833
+ k, v = _parse_kv(kv)
834
+ dataset_aux[k] = v
835
+ zone_groups.extend(_to_groups(args.zone))
836
+ var_groups.extend(_to_groups(args.var))
837
+ except _ArgError as exc:
838
+ print(f"Error: {exc}", file=sys.stderr)
839
+ return 1
840
+
841
+ if not (dataset_aux or zone_groups or var_groups):
842
+ print(
843
+ "Warning: no -d, -z, -v, or -j given -- output will be a verbatim copy.",
844
+ file=sys.stderr,
845
+ )
846
+
847
+ try:
848
+ with tecio_open(str(src), "r") as reader:
849
+ var_names: list[str] = reader.variables
850
+ num_vars: int = reader.num_vars
851
+ num_zones: int = reader.num_zones
852
+
853
+ def _resolve_zone_target(raw: str) -> int:
854
+ try:
855
+ idx = int(raw)
856
+ except ValueError as exc:
857
+ raise _ArgError(
858
+ f"Zone target must be an index or 'all', got: {raw!r}"
859
+ ) from exc
860
+ if idx < 1 or idx > num_zones:
861
+ raise _ArgError(f"Zone index {idx} out of range [1, {num_zones}].")
862
+ return idx
863
+
864
+ def _resolve_var_target(raw: str) -> int:
865
+ idx0 = _resolve_variable(raw, var_names)
866
+ if idx0 is None:
867
+ raise _ArgError(
868
+ f"Could not resolve variable target {raw!r}. "
869
+ f"Available: {var_names}"
870
+ )
871
+ return idx0 + 1 # keyed 1-based, matching zone targets
872
+
873
+ try:
874
+ zone_broadcast, zone_by_target = _consolidate_groups(
875
+ zone_groups, _resolve_zone_target
876
+ )
877
+ var_broadcast, var_by_target = _consolidate_groups(
878
+ var_groups, _resolve_var_target
879
+ )
880
+ except _ArgError as exc:
881
+ print(f"Error: {exc}", file=sys.stderr)
882
+ return 1
883
+
884
+ print(f"Adding auxiliary data: {src} -> {dst}")
885
+ if dataset_aux:
886
+ print(f" Dataset : {dataset_aux}")
887
+ for target, pairs in zone_groups:
888
+ where = f"zone {target}" if target is not None else "every zone"
889
+ print(f" Zone : {pairs} ({where})")
890
+ for target, pairs in var_groups:
891
+ where = f"variable {target}" if target is not None else "every variable"
892
+ print(f" Variable: {pairs} ({where})")
893
+
894
+ with tecio_open(
895
+ str(dst),
896
+ "w",
897
+ title=reader.title,
898
+ variables=var_names,
899
+ file_type=reader.file_type,
900
+ ) as writer:
901
+ merged_dataset_aux = {**dict(reader.auxdata.items()), **dataset_aux}
902
+ if merged_dataset_aux:
903
+ writer.add_auxdataset_dict(merged_dataset_aux)
904
+
905
+ auxvar: dict[int, dict[str, str]] = {}
906
+ for i in range(num_vars):
907
+ one_based = i + 1
908
+ existing = dict(reader.get_var_auxdata(one_based).items())
909
+ merged = {
910
+ **existing,
911
+ **var_broadcast,
912
+ **var_by_target.get(one_based, {}),
913
+ }
914
+ if merged:
915
+ auxvar[one_based] = merged
916
+ if auxvar:
917
+ writer.add_auxvar_dict(auxvar)
918
+
919
+ # add_auxdataset_dict()/add_auxvar_dict() only buffer; the write happens
920
+ # in flush_aux(), normally auto-triggered by the *lazy*-open path on the
921
+ # first zone write. Passing variables= above means this writer is
922
+ # already open (eager), so that automatic trigger never fires.
923
+ writer.flush_aux()
924
+
925
+ for i, zone in enumerate(reader.zone):
926
+ zone_num = i + 1
927
+ zt = zone.zone_type
928
+
929
+ if zt in _FE_POLY:
930
+ print(
931
+ f"Warning: zone {zone_num} ('{zone.title}') is "
932
+ f"{zt.name} and cannot be copied -- skipping.",
933
+ file=sys.stderr,
934
+ )
935
+ continue
936
+
937
+ (
938
+ writer_data,
939
+ writer_locs,
940
+ passive_vars,
941
+ var_sharing,
942
+ existing_zone_aux,
943
+ ) = _process_zone(zone)
944
+
945
+ merged_zone_aux = {
946
+ **existing_zone_aux,
947
+ **zone_broadcast,
948
+ **zone_by_target.get(zone_num, {}),
949
+ } or None
950
+
951
+ _write_zone_data(
952
+ writer,
953
+ zone,
954
+ writer_data,
955
+ writer_locs,
956
+ passive_vars,
957
+ var_sharing,
958
+ merged_zone_aux,
959
+ )
960
+
961
+ except Exception as exc: # noqa: BLE001
962
+ print(f"Error: {exc}", file=sys.stderr)
963
+ dst.unlink(missing_ok=True)
964
+ return 1
965
+
966
+ print(f"Done. Output written to: {dst}")
967
+ return 0
968
+
969
+
970
+ if __name__ == "__main__":
971
+ sys.exit(main())