tecio-python 0.2.2__py3-none-any.whl → 0.2.3__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.
@@ -0,0 +1,1070 @@
1
+ """ParaView reader plugin for Tecplot 360 data files, built on ``tecio``.
2
+
3
+ This plugin closes three gaps in ParaView's built-in Tecplot reader: it cannot read
4
+ ``.szplt``, it errors out on some ASCII zone header fields, and does not read auxiliary
5
+ data as VTK :class:`~vtkmodules.vtkCommonDataModel. vtkFieldData`.
6
+
7
+ Output structure
8
+ Each file is read into one ``vtkMultiBlockDataSet``, one block per zone, named after
9
+ the zone's title (or ``"Zone <n>"`` if untitled):
10
+
11
+ * ``ORDERED`` zones become ``vtkStructuredGrid`` blocks.
12
+ * Classic finite-element zones (line, triangle, quadrilateral, tetrahedron, brick)
13
+ become ``vtkUnstructuredGrid`` blocks.
14
+ * ``FEPOLYGON``, ``FEPOLYHEDRON``, and ``FEMIXED`` zones are not yet supported and
15
+ are skipped with a printed warning.
16
+ * A zone whose aux data marks it a non-``Wall`` boundary (see "Zone visibility"
17
+ below) still becomes a block, but its entry in the ``Zones`` property defaults
18
+ to unchecked, matching Tecplot's own default display convention.
19
+
20
+ Auxiliary data has no first-class equivalent in VTK, so it is mapped onto the
21
+ nearest matching :class:`vtkFieldData`, each item as a one-element array named after
22
+ the aux-data key -- numeric (int64/float64) when the value parses cleanly as a
23
+ number, else a :class:`vtkStringArray`. Tecplot itself treats aux data as usable
24
+ scalars in its own equations, and a numeric VTK array is the equivalent for
25
+ ParaView: it works directly in the Python Calculator, Calculator filter, and
26
+ numeric-oriented annotation filters, not just as inert display text.
27
+
28
+ * Dataset-level aux data -> the root ``vtkMultiBlockDataSet``'s field data.
29
+ * Variable-level aux data -> also the root field data, keyed ``"<variable
30
+ name>::<aux key>"`` (there is no per-array metadata slot that ParaView's UI
31
+ surfaces to users).
32
+ * Zone-level aux data -> that zone's own block's field data.
33
+
34
+ Coordinates
35
+ Tecplot data files do not tag which variables are spatial coordinates (that is
36
+ ordinarily a plot/frame setting, not a file-format concept), so this reader resolves
37
+ each axis in priority order:
38
+
39
+ 1. The ``XArray``/``YArray``/``ZArray`` properties, if set to a real variable name.
40
+ 2. The dataset aux data items ``Common.XVar``/``Common.YVar``/``Common.ZVar``, per
41
+ the Tecplot data format guide -- each holds the 1-based dataset variable number
42
+ to use for that axis, when the file itself specifies one.
43
+ 3. A name match against some spelling of ``x``/``y``/``z`` (case-insensitively,
44
+ e.g. ``"X"``, ``"x-coordinate"``).
45
+ 4. For X and Y only, positional fallback to the first and second dataset variables,
46
+ since almost every Tecplot CFD file leads with its coordinates. Z has no
47
+ positional fallback beyond the third variable and defaults to all-zero (2-D
48
+ data) when nothing is found by steps 1-3.
49
+
50
+ Vector components
51
+ If the dataset aux data specifies ``Common.UVar``/``Common.VVar``/``Common.WVar``
52
+ (or the ``UArray``/``VArray``/``WArray`` properties are set manually), this reader
53
+ assembles those component variables into one 3-component array per zone so glyphs,
54
+ streamlines, and vector-magnitude coloring work without a separate Calculator
55
+ step. Unlike coordinates, there's no name-based guessing here (solver vector
56
+ naming varies too much to guess reliably), so nothing is built at all unless at
57
+ least one of U/V/W actually resolves; a resolved-but-missing component (2-D flow,
58
+ or passive in a particular zone) is treated as zero rather than blocking the
59
+ other components. The array is named ``"Velocity"`` if the dataset aux item
60
+ ``Common.VectorVarsAreVelocity`` is truthy, else ``"Momentum"``.
61
+
62
+ Zone visibility
63
+ Tecplot sets default visiblilty for zones with auxdata that marks them a boundary
64
+ that isn't a wall (``Common.IsBoundaryZone`` true and ``Common.BoundaryCondition``
65
+ not ``"Wall"``).
66
+
67
+ Known limitations (first pass)
68
+ * ``FEPOLYGON`` / ``FEPOLYHEDRON`` / ``FEMIXED`` zones are skipped. Note that
69
+ ``tecio``'s ASCII DAT reader currently raises immediately on encountering one of
70
+ these zone types (rather than skipping just that zone), so a ``.dat`` file
71
+ containing one won't load at all through this reader until ``tecio`` supports it.
72
+ * SOLUTION-only files (grid coordinates stored in a separate, unopened GRID file)
73
+ have no coordinate data to build geometry from and are not handled specially; they
74
+ will fail coordinate resolution unless the variables happen to still be present.
75
+ * Face-based connectivity, custom labels, geometry/text annotations, and Tecplot's
76
+ classic-PLT auxiliary record types outside dataset/zone/ variable aux data are not
77
+ handled by ``tecio`` data readers.
78
+
79
+ Requirements
80
+ The ``tecio`` package must be importable from the same Python interpreter ParaView
81
+ uses to run this plugin.
82
+ """
83
+
84
+ from __future__ import annotations
85
+
86
+ import math
87
+ import os
88
+ import sys
89
+ import weakref
90
+ from typing import TYPE_CHECKING, Any
91
+
92
+ import numpy as np
93
+ import numpy.typing as npt
94
+ from paraview.util.vtkAlgorithm import smdomain, smhint, smproperty, smproxy
95
+ from vtkmodules.util import numpy_support
96
+ from vtkmodules.util.vtkAlgorithm import VTKPythonAlgorithmBase
97
+ from vtkmodules.vtkCommonCore import vtkDataArraySelection, vtkPoints, vtkStringArray
98
+ from vtkmodules.vtkCommonDataModel import (
99
+ VTK_HEXAHEDRON,
100
+ VTK_LINE,
101
+ VTK_QUAD,
102
+ VTK_TETRA,
103
+ VTK_TRIANGLE,
104
+ vtkCellArray,
105
+ vtkCompositeDataSet,
106
+ vtkFieldData,
107
+ vtkMultiBlockDataSet,
108
+ vtkStructuredGrid,
109
+ vtkUnstructuredGrid,
110
+ )
111
+
112
+ # Because ParaView's bundled Python (vtkpython) has its own write-protected
113
+ # site-packages tecio cannot directly be installed into the pvpython environment,
114
+ # create a TECIO_PYTHONPATH environment variable to point to the local install
115
+ # (e.g. "/Users/you/tecio-for-paraview").
116
+ _TECIO_FALLBACK_PATH = os.environ.get("TECIO_PYTHONPATH", "")
117
+ if _TECIO_FALLBACK_PATH and _TECIO_FALLBACK_PATH not in sys.path:
118
+ sys.path.insert(0, _TECIO_FALLBACK_PATH)
119
+
120
+ try:
121
+ import tecio
122
+ from tecio.libtecio import ValueLocation, ZoneType
123
+
124
+ _TECIO_IMPORT_ERROR: Exception | None = None
125
+ except Exception as exc: # pragma: no cover - environment-dependent # noqa: BLE001
126
+ tecio = None # ty: ignore[invalid-assignment]
127
+ ValueLocation = ZoneType = None # ty: ignore[invalid-assignment]
128
+ _TECIO_IMPORT_ERROR = exc
129
+
130
+ if TYPE_CHECKING:
131
+ from tecio.dat import Read as _DatRead
132
+ from tecio.plt import Read as _PltRead
133
+ from tecio.szl import Read as _SzlRead
134
+
135
+ _TecioReader = _DatRead | _PltRead | _SzlRead
136
+
137
+ # --------------------------------------------------------------------------------------
138
+ # Module-level constants and small helpers
139
+ # --------------------------------------------------------------------------------------
140
+
141
+ _EXTENSIONS = "szplt plt bin dat tec"
142
+ _FILE_DESCRIPTION = "Tecplot 360 Data Files"
143
+
144
+ # Maps a supported classic finite-element ZoneType to its VTK cell type
145
+ _VTK_CELL_TYPE: dict[Any, int] = (
146
+ {
147
+ ZoneType.FELINESEG: VTK_LINE,
148
+ ZoneType.FETRIANGLE: VTK_TRIANGLE,
149
+ ZoneType.FEQUADRILATERAL: VTK_QUAD,
150
+ ZoneType.FETETRAHEDRON: VTK_TETRA,
151
+ ZoneType.FEBRICK: VTK_HEXAHEDRON,
152
+ }
153
+ if tecio is not None
154
+ else {}
155
+ )
156
+
157
+ # Exact-match (case-insensitive, trimmed) spellings of each coordinate axis
158
+ _AXIS_SYNONYMS: dict[str, frozenset[str]] = {
159
+ "x": frozenset({
160
+ "x",
161
+ "x-coordinate",
162
+ "x coordinate",
163
+ "coordinate x",
164
+ "coord-x",
165
+ "coordx",
166
+ "xcoord",
167
+ "x_coord",
168
+ }),
169
+ "y": frozenset({
170
+ "y",
171
+ "y-coordinate",
172
+ "y coordinate",
173
+ "coordinate y",
174
+ "coord-y",
175
+ "coordy",
176
+ "ycoord",
177
+ "y_coord",
178
+ }),
179
+ "z": frozenset({
180
+ "z",
181
+ "z-coordinate",
182
+ "z coordinate",
183
+ "coordinate z",
184
+ "coord-z",
185
+ "coordz",
186
+ "zcoord",
187
+ "z_coord",
188
+ }),
189
+ }
190
+
191
+
192
+ # Case-insensitive truthy spellings Tecplot recognizes for Common.IsBoundaryZone
193
+ _BOUNDARY_ZONE_TRUE_VALUES = frozenset({"yes", "y", "true", "t", "on"})
194
+
195
+
196
+ def _is_deactivated_boundary_zone(zone: Any) -> bool:
197
+ """Return True if *zone* should default to hidden, per Tecplot's own convention.
198
+
199
+ Mirrors Tecplot 360's documented behavior for the ``Common.IsBoundaryZone`` /
200
+ ``Common.BoundaryCondition`` zone aux data pair: a zone marked a boundary is
201
+ deactivated (hidden) by default unless its boundary condition is exactly
202
+ ``"Wall"``. Only the default *initial* state is affected -- the zone still loads
203
+ as a block either way, and its checkbox in the ``Zones`` property can always be
204
+ re-enabled.
205
+ """
206
+ aux = zone.auxdata
207
+ is_boundary = aux.get("Common.IsBoundaryZone", "").strip().lower()
208
+ if is_boundary not in _BOUNDARY_ZONE_TRUE_VALUES:
209
+ return False
210
+ return aux.get("Common.BoundaryCondition", "") != "Wall"
211
+
212
+
213
+ class _UnsupportedZoneError(Exception):
214
+ """Raised internally when a zone's geometry can't be represented in VTK.
215
+
216
+ Caught by :meth:`TecplotReader.RequestData`, which prints a warning and skips the
217
+ offending zone rather than failing the whole read.
218
+ """
219
+
220
+
221
+ def _modified_callback(target: VTKPythonAlgorithmBase):
222
+ """Return a callback that calls ``target.Modified()`` while it's alive.
223
+
224
+ Wires a :class:`vtkDataArraySelection`'s ``ModifiedEvent`` back to the owning reader
225
+ so that toggling an array/zone checkbox in the ParaView UI correctly marks the
226
+ pipeline as needing re-execution. A weak reference avoids keeping the reader alive
227
+ solely because the observer closure refers to it.
228
+ """
229
+ ref = weakref.ref(target)
230
+
231
+ def _callback(*_args: Any) -> None:
232
+ obj = ref()
233
+ if obj is not None:
234
+ obj.Modified()
235
+
236
+ return _callback
237
+
238
+
239
+ def _autodetect_axis(names: list[str], axis: str) -> str | None:
240
+ """Return the variable name in *names* that looks like *axis*, or ``None``."""
241
+ synonyms = _AXIS_SYNONYMS[axis]
242
+ for name in names:
243
+ if name.strip().lower() in synonyms:
244
+ return name
245
+ return None
246
+
247
+
248
+ def _resolve_common_var(reader: _TecioReader, key: str) -> str | None:
249
+ """Resolve a ``Common.<Something>Var`` dataset aux item to a variable name.
250
+
251
+ Per the Tecplot data format guide, items such as ``Common.XVar`` and
252
+ ``Common.UVar`` hold a 1-based dataset variable number when present. Returns
253
+ ``None`` if *key* is absent from the dataset aux data, isn't a valid integer, or
254
+ is out of range for the dataset -- the caller decides what to fall back to.
255
+ """
256
+ index = reader.auxdata.as_int(key)
257
+ if index is None:
258
+ return None
259
+ if not 1 <= index <= reader.num_vars:
260
+ print(
261
+ f"[TecplotTecioReader] {key} = {index} is out of range "
262
+ f"[1, {reader.num_vars}]; ignoring."
263
+ )
264
+ return None
265
+ return reader.variables[index - 1]
266
+
267
+
268
+ def _coerce_aux_value(value: str) -> int | float | str:
269
+ """Best-effort convert an aux-data string to ``int`` or ``float``.
270
+
271
+ Tecplot itself treats aux data as usable scalars in its own equations, so a value
272
+ that parses cleanly as a number is returned as that type; anything else is left as
273
+ the original string. ``int()`` is tried before ``float()`` so an integer-looking
274
+ value (e.g. the variable number in ``Common.XVar``) becomes a integer rather than a
275
+ ``1.0``-style float.
276
+ """
277
+ try:
278
+ return int(value)
279
+ except ValueError:
280
+ pass
281
+ try:
282
+ return float(value)
283
+ except ValueError:
284
+ return value
285
+
286
+
287
+ def _add_aux_field_data(
288
+ field_data: vtkFieldData,
289
+ items: Any,
290
+ key_fn: Any = None,
291
+ ) -> None:
292
+ """Attach each ``(name, value)`` pair in *items* to *field_data*.
293
+
294
+ Each pair becomes a one-element array on *field_data*: a numeric int64/float64
295
+ array (see :func:`_coerce_aux_value`) when the value parses cleanly as a number,
296
+ else a :class:`vtkStringArray`. This is the closest practical VTK equivalent to
297
+ Tecplot's auxiliary data -- there is no first-class "aux data" concept in VTK, but
298
+ field data is visible in ParaView's Information panel and Spreadsheet View, and a
299
+ numeric array additionally works directly with numeric-oriented tools such as the
300
+ Python Calculator and Annotate Attribute Data filter.
301
+
302
+ Args:
303
+ field_data: Target field data (root dataset, block, etc.).
304
+ items: Iterable of ``(key, value)`` string pairs, e.g. a dict's ``.items()``.
305
+ key_fn: Optional ``key -> array name`` transform, e.g. to prefix variable-level
306
+ aux data with the variable name.
307
+ """
308
+ for key, value in items:
309
+ name = key_fn(key) if key_fn is not None else key
310
+ coerced = _coerce_aux_value(value)
311
+
312
+ if isinstance(coerced, str):
313
+ arr = vtkStringArray()
314
+ arr.SetName(name)
315
+ arr.SetNumberOfValues(1)
316
+ arr.SetValue(0, coerced)
317
+ else:
318
+ dtype = np.int64 if isinstance(coerced, int) else np.float64
319
+ arr = numpy_support.numpy_to_vtk(
320
+ np.array([coerced], dtype=dtype), deep=True
321
+ )
322
+ arr.SetName(name)
323
+
324
+ field_data.AddArray(arr)
325
+
326
+
327
+ # ======================================================================================
328
+ # Reader
329
+ # ======================================================================================
330
+
331
+
332
+ @smproxy.reader(
333
+ name="TecplotTecioReader",
334
+ label="Tecplot 360 Reader (TecIO)",
335
+ extensions=_EXTENSIONS,
336
+ file_description=_FILE_DESCRIPTION,
337
+ )
338
+ class TecplotReader(VTKPythonAlgorithmBase):
339
+ """Reads Tecplot SZL/PLT/DAT files into a ``vtkMultiBlockDataSet``.
340
+
341
+ One block per zone; see the module docstring for the full mapping, including how
342
+ auxiliary data becomes field data and how coordinate variables are chosen.
343
+ """
344
+
345
+ def __init__(self) -> None:
346
+ """Initialize from VTK object."""
347
+ super().__init__(
348
+ nInputPorts=0, nOutputPorts=1, outputType="vtkMultiBlockDataSet"
349
+ )
350
+
351
+ self._filename: str | None = None
352
+ self._reader: _TecioReader | None = None
353
+ self._reader_path: str | None = None
354
+ self._reader_mtime: float | None = None
355
+
356
+ self._x_override: str | None = None
357
+ self._y_override: str | None = None
358
+ self._z_override: str | None = None
359
+ self._u_override: str | None = None
360
+ self._v_override: str | None = None
361
+ self._w_override: str | None = None
362
+
363
+ self._array_selection = vtkDataArraySelection()
364
+ self._array_selection.AddObserver("ModifiedEvent", _modified_callback(self))
365
+
366
+ # Keyed "<1-based zone index>: <title>" -> whether that zone/block loads.
367
+ self._zone_selection = vtkDataArraySelection()
368
+ self._zone_selection.AddObserver("ModifiedEvent", _modified_callback(self))
369
+ self._zone_keys: dict[int, str] = {}
370
+
371
+ def __del__(self) -> None: # pragma: no cover
372
+ """Best-effort cleanup."""
373
+ try:
374
+ self._close_reader()
375
+ except Exception: # noqa: BLE001 - never raise from __del__
376
+ pass
377
+
378
+ # -- File handle management -------------------------------------------------------
379
+
380
+ def _close_reader(self) -> None:
381
+ """Release the cached reader handle, if any."""
382
+ if self._reader is not None:
383
+ close = getattr(self._reader, "close", None)
384
+ if callable(close):
385
+ try:
386
+ close()
387
+ except Exception: # noqa: BLE001 - closing must not fail the pipeline
388
+ pass
389
+ self._reader = None
390
+ self._reader_path = None
391
+ self._reader_mtime = None
392
+
393
+ def _get_reader(self) -> _TecioReader | None:
394
+ """Return a cached, open ``tecio`` reader for the current file.
395
+
396
+ Reopens only when the path or on-disk modification time changes, so the
397
+ frequent, metadata-only pipeline callbacks ParaView issues (array lists, time
398
+ values, ...) don't repeatedly pay the cost of reopening the file.
399
+
400
+ Returns:
401
+ An open ``tecio`` reader, or ``None`` if no file has been set.
402
+
403
+ Raises:
404
+ RuntimeError: If the ``tecio`` package could not be imported.
405
+ FileNotFoundError: If the configured file can't be found/read.
406
+ """
407
+ if self._filename is None:
408
+ return None
409
+ if tecio is None:
410
+ raise RuntimeError(
411
+ "The 'tecio' package is not importable in ParaView's Python "
412
+ f"environment ({_TECIO_IMPORT_ERROR!r}). Install tecio (and "
413
+ "make sure the TecIO shared library can be located, e.g. via "
414
+ "the TECIO_LIB environment variable) in the Python "
415
+ "interpreter ParaView uses, then reload this plugin."
416
+ )
417
+
418
+ try:
419
+ mtime = os.path.getmtime(self._filename)
420
+ except OSError as exc:
421
+ raise FileNotFoundError(
422
+ f"Cannot read Tecplot file: {self._filename}"
423
+ ) from exc
424
+
425
+ if (
426
+ self._reader is not None
427
+ and self._reader_path == self._filename
428
+ and self._reader_mtime == mtime
429
+ ):
430
+ return self._reader
431
+
432
+ self._close_reader()
433
+ self._reader = tecio.open(self._filename, "r")
434
+ self._reader_path = self._filename
435
+ self._reader_mtime = mtime
436
+ self._sync_selections(self._reader)
437
+ return self._reader
438
+
439
+ def _sync_selections(self, reader: _TecioReader) -> None:
440
+ """(Re)populate the array and zone selections for a newly opened file."""
441
+ self._array_selection.RemoveAllArrays()
442
+ for name in reader.variables:
443
+ self._array_selection.AddArray(name)
444
+
445
+ self._zone_selection.RemoveAllArrays()
446
+ self._zone_keys.clear()
447
+ for zone in reader.zone:
448
+ title = zone.title or f"Zone {zone.zone_index}"
449
+ key = f"{zone.zone_index}: {title}"
450
+ self._zone_keys[zone.zone_index] = key
451
+ default_enabled = not _is_deactivated_boundary_zone(zone)
452
+ self._zone_selection.AddArray(key, default_enabled)
453
+
454
+ # -- FileName --------------------------------------------------------------------
455
+
456
+ @smproperty.stringvector(name="FileName")
457
+ @smdomain.filelist()
458
+ @smhint.filechooser(extensions=_EXTENSIONS, file_description=_FILE_DESCRIPTION)
459
+ def SetFileName(self, name: str | None) -> None:
460
+ """Set the path to the Tecplot file.
461
+
462
+ Accepts ``.szplt``/``.plt``/``.bin``/``.dat``/``.tec``.
463
+ """
464
+ name = name or None
465
+ if self._filename != name:
466
+ self._filename = name
467
+ self._close_reader()
468
+ self.Modified()
469
+
470
+ # -- Array / zone selection --------------------------------------------------------
471
+
472
+ @smproperty.dataarrayselection(name="Arrays")
473
+ def GetDataArraySelection(self) -> vtkDataArraySelection:
474
+ """Which dataset variables load as point/cell data arrays."""
475
+ return self._array_selection
476
+
477
+ @smproperty.dataarrayselection(name="Zones")
478
+ def GetZoneSelection(self) -> vtkDataArraySelection:
479
+ """Which zones load as blocks -- useful to skip zones in large files."""
480
+ return self._zone_selection
481
+
482
+ # -- Coordinate variable overrides ------------------------------------------------
483
+
484
+ @smproperty.stringvector(name="VariableInfo", information_only="1")
485
+ def GetVariableInfo(self) -> list[str]:
486
+ """Choices offered by the X/Y/Z array dropdowns.
487
+
488
+ Eg ``"(auto)"`` plus every variable.
489
+ """
490
+ try:
491
+ reader = self._get_reader()
492
+ except Exception: # noqa: BLE001 - domain refresh must not raise into the UI
493
+ reader = None
494
+ names = list(reader.variables) if reader is not None else []
495
+ return ["(auto)", *names]
496
+
497
+ def _set_axis_override(self, axis: str, value: str) -> None:
498
+ resolved = None if value in ("", "(auto)") else value
499
+ attr = f"_{axis}_override"
500
+ if getattr(self, attr) != resolved:
501
+ setattr(self, attr, resolved)
502
+ self.Modified()
503
+
504
+ @smproperty.stringvector(
505
+ name="XArray", number_of_elements="1", default_values=["(auto)"]
506
+ )
507
+ @smdomain.xml("""
508
+ <StringListDomain name="list">
509
+ <RequiredProperties>
510
+ <Property name="VariableInfo" function="StringInfo"/>
511
+ </RequiredProperties>
512
+ </StringListDomain>
513
+ """)
514
+ def SetXArray(self, value: str) -> None:
515
+ """Override auto-detection of the X-coordinate variable."""
516
+ self._set_axis_override("x", value)
517
+
518
+ @smproperty.stringvector(
519
+ name="YArray", number_of_elements="1", default_values=["(auto)"]
520
+ )
521
+ @smdomain.xml("""
522
+ <StringListDomain name="list">
523
+ <RequiredProperties>
524
+ <Property name="VariableInfo" function="StringInfo"/>
525
+ </RequiredProperties>
526
+ </StringListDomain>
527
+ """)
528
+ def SetYArray(self, value: str) -> None:
529
+ """Override auto-detection of the Y-coordinate variable."""
530
+ self._set_axis_override("y", value)
531
+
532
+ @smproperty.stringvector(
533
+ name="ZArray", number_of_elements="1", default_values=["(auto)"]
534
+ )
535
+ @smdomain.xml("""
536
+ <StringListDomain name="list">
537
+ <RequiredProperties>
538
+ <Property name="VariableInfo" function="StringInfo"/>
539
+ </RequiredProperties>
540
+ </StringListDomain>
541
+ """)
542
+ def SetZArray(self, value: str) -> None:
543
+ """Override auto-detection of the Z-coordinate variable (else 2-D: Z=0)."""
544
+ self._set_axis_override("z", value)
545
+
546
+ @smproperty.stringvector(
547
+ name="UArray", number_of_elements="1", default_values=["(auto)"]
548
+ )
549
+ @smdomain.xml("""
550
+ <StringListDomain name="list">
551
+ <RequiredProperties>
552
+ <Property name="VariableInfo" function="StringInfo"/>
553
+ </RequiredProperties>
554
+ </StringListDomain>
555
+ """)
556
+ def SetUArray(self, value: str) -> None:
557
+ """Override the vector-array U component (else ``Common.UVar``, if set)."""
558
+ self._set_axis_override("u", value)
559
+
560
+ @smproperty.stringvector(
561
+ name="VArray", number_of_elements="1", default_values=["(auto)"]
562
+ )
563
+ @smdomain.xml("""
564
+ <StringListDomain name="list">
565
+ <RequiredProperties>
566
+ <Property name="VariableInfo" function="StringInfo"/>
567
+ </RequiredProperties>
568
+ </StringListDomain>
569
+ """)
570
+ def SetVArray(self, value: str) -> None:
571
+ """Override the vector-array V component (else ``Common.VVar``, if set)."""
572
+ self._set_axis_override("v", value)
573
+
574
+ @smproperty.stringvector(
575
+ name="WArray", number_of_elements="1", default_values=["(auto)"]
576
+ )
577
+ @smdomain.xml("""
578
+ <StringListDomain name="list">
579
+ <RequiredProperties>
580
+ <Property name="VariableInfo" function="StringInfo"/>
581
+ </RequiredProperties>
582
+ </StringListDomain>
583
+ """)
584
+ def SetWArray(self, value: str) -> None:
585
+ """Override the vector-array W component (else ``Common.WVar``, if set)."""
586
+ self._set_axis_override("w", value)
587
+
588
+ def _resolve_vector_component_names(
589
+ self, reader: _TecioReader
590
+ ) -> tuple[str | None, str | None, str | None]:
591
+ """Pick the dataset variables to use as the U, V, W vector components.
592
+
593
+ Unlike coordinates, there's no name-based or positional fallback here. Tecplot
594
+ vector-variable names (velocity or momentum components) vary too much across
595
+ solvers to guess reliably. A component resolves only via a manual override
596
+ (``UArray``/``VArray``/``WArray``) or the file's own
597
+ ``Common.UVar``/``Common.VVar``/``Common.WVar`` dataset aux data; anything left
598
+ unresolved stays ``None`` and is treated as zero when the vector is assembled,
599
+ so a 2-D (U, V only) flow still gets a valid, glyph-able vector.
600
+ """
601
+ names = list(reader.variables)
602
+
603
+ def override(axis: str) -> str | None:
604
+ value = getattr(self, f"_{axis}_override")
605
+ return value if value in names else None
606
+
607
+ u = override("u") or _resolve_common_var(reader, "Common.UVar")
608
+ v = override("v") or _resolve_common_var(reader, "Common.VVar")
609
+ w = override("w") or _resolve_common_var(reader, "Common.WVar")
610
+ return u, v, w
611
+
612
+ @staticmethod
613
+ def _resolve_vector_array_name(reader: _TecioReader) -> str:
614
+ """Return "Velocity" or "Momentum" per Common.VectorVarsAreVelocity.
615
+
616
+ Per the Tecplot data format guide: if that dataset aux item is truthy, the
617
+ U/V/W vector is velocity; otherwise Tecplot's own documented default is to
618
+ assume momentum, which this mirrors.
619
+ """
620
+ is_velocity = reader.auxdata.as_bool("Common.VectorVarsAreVelocity")
621
+ return "Velocity" if is_velocity else "Momentum"
622
+
623
+ def _resolve_coordinate_names(
624
+ self, reader: _TecioReader
625
+ ) -> tuple[str | None, str | None, str | None]:
626
+ """Pick the dataset variables to use as X, Y, and Z point coordinates.
627
+
628
+ Priority order: manual override, then the ``Common.XVar``/``YVar``/``ZVar``
629
+ dataset aux items if the file specifies them, then a name match (see
630
+ :func:`_autodetect_axis`), then -- for X and Y only -- positional fallback to
631
+ the first and second dataset variables, since almost every Tecplot CFD file
632
+ leads with its coordinates. Z has no positional fallback beyond the third
633
+ variable and defaults to all-zero (2-D data) when nothing is found.
634
+ """
635
+ names = list(reader.variables)
636
+
637
+ def override(axis: str) -> str | None:
638
+ value = getattr(self, f"_{axis}_override")
639
+ return value if value in names else None
640
+
641
+ x = (
642
+ override("x")
643
+ or _resolve_common_var(reader, "Common.XVar")
644
+ or _autodetect_axis(names, "x")
645
+ )
646
+ y = (
647
+ override("y")
648
+ or _resolve_common_var(reader, "Common.YVar")
649
+ or _autodetect_axis(names, "y")
650
+ )
651
+ z = (
652
+ override("z")
653
+ or _resolve_common_var(reader, "Common.ZVar")
654
+ or _autodetect_axis(names, "z")
655
+ )
656
+
657
+ if x is None and names:
658
+ x = names[0]
659
+ if y is None and len(names) > 1:
660
+ y = names[1]
661
+ if z is None and len(names) > 2 and names[2] not in (x, y):
662
+ z = names[2]
663
+
664
+ return x, y, z
665
+
666
+ # -- Time -----------------------------------------------------------------------
667
+
668
+ def _compute_timesteps(self, reader: _TecioReader) -> list[float]:
669
+ """Sorted, distinct solution times across all non-static zones.
670
+
671
+ Zones with ``strand_id == 0`` are the standard Tecplot convention for
672
+ static/always-present geometry, so they're excluded from the time axis and
673
+ instead included at every requested time (see :meth:`RequestData`).
674
+ """
675
+ times = {zone.solution_time for zone in reader.zone if zone.strand_id != 0}
676
+ return sorted(times)
677
+
678
+ @smproperty.doublevector(
679
+ name="TimestepValues", information_only="1", si_class="vtkSITimeStepsProperty"
680
+ )
681
+ def GetTimestepValues(self) -> list[float]:
682
+ """Publish the dataset's discrete solution times to ParaView's time UI."""
683
+ try:
684
+ reader = self._get_reader()
685
+ except Exception: # noqa: BLE001 - domain refresh must not raise into the UI
686
+ return []
687
+ return self._compute_timesteps(reader) if reader is not None else []
688
+
689
+ def _get_update_time(self, out_info: Any) -> float | None:
690
+ """Resolve the pipeline's requested time to one of the dataset's own times."""
691
+ reader = self._reader
692
+ if reader is None:
693
+ return None
694
+ timesteps = self._compute_timesteps(reader)
695
+ if not timesteps:
696
+ return None
697
+
698
+ executive = self.GetExecutive()
699
+ if out_info.Has(executive.UPDATE_TIME_STEP()):
700
+ requested = out_info.Get(executive.UPDATE_TIME_STEP())
701
+ result = timesteps[0]
702
+ for t in timesteps:
703
+ if t <= requested + 1e-9:
704
+ result = t
705
+ else:
706
+ break
707
+ return result
708
+ return timesteps[0]
709
+
710
+ # -- Pipeline: RequestInformation --------------------------------------------------
711
+
712
+ def RequestInformation(self, request, inInfoVec, outInfoVec) -> int:
713
+ """Publish the dataset's time steps, if any, before ``RequestData`` runs."""
714
+ try:
715
+ reader = self._get_reader()
716
+ except Exception as exc: # noqa: BLE001 - report and produce empty output
717
+ print(f"[TecplotTecioReader] {exc}")
718
+ return 1
719
+ if reader is None:
720
+ return 1
721
+
722
+ executive = self.GetExecutive()
723
+ out_info = outInfoVec.GetInformationObject(0)
724
+ out_info.Remove(executive.TIME_STEPS())
725
+ out_info.Remove(executive.TIME_RANGE())
726
+
727
+ timesteps = self._compute_timesteps(reader)
728
+ if timesteps:
729
+ for t in timesteps:
730
+ out_info.Append(executive.TIME_STEPS(), t)
731
+ out_info.Append(executive.TIME_RANGE(), timesteps[0])
732
+ out_info.Append(executive.TIME_RANGE(), timesteps[-1])
733
+ return 1
734
+
735
+ # -- Pipeline: RequestData ---------------------------------------------------------
736
+
737
+ def RequestData(self, request, inInfoVec, outInfoVec) -> int:
738
+ """Build the ``vtkMultiBlockDataSet`` output for the requested time step."""
739
+ output = vtkMultiBlockDataSet.GetData(outInfoVec, 0)
740
+
741
+ try:
742
+ reader = self._get_reader()
743
+ except Exception as exc: # noqa: BLE001 - report and produce empty output
744
+ print(f"[TecplotTecioReader] {exc}")
745
+ return 1
746
+ if reader is None:
747
+ return 1
748
+
749
+ requested_time = self._get_update_time(outInfoVec.GetInformationObject(0))
750
+ x_name, y_name, z_name = self._resolve_coordinate_names(reader)
751
+ u_name, v_name, w_name = self._resolve_vector_component_names(reader)
752
+ vector_array_name = self._resolve_vector_array_name(reader)
753
+
754
+ _add_aux_field_data(output.GetFieldData(), reader.auxdata.items())
755
+ self._add_variable_aux_field_data(output.GetFieldData(), reader)
756
+
757
+ block_index = 0
758
+ for zone in reader.zone:
759
+ key = self._zone_keys.get(zone.zone_index)
760
+ if key is not None and not self._zone_selection.ArrayIsEnabled(key):
761
+ continue
762
+ if (
763
+ requested_time is not None
764
+ and zone.strand_id != 0
765
+ and not math.isclose(zone.solution_time, requested_time, abs_tol=1e-9)
766
+ ):
767
+ continue
768
+
769
+ try:
770
+ dataset = self._build_zone_dataset(
771
+ zone,
772
+ x_name,
773
+ y_name,
774
+ z_name,
775
+ u_name,
776
+ v_name,
777
+ w_name,
778
+ vector_array_name,
779
+ )
780
+ except _UnsupportedZoneError as exc:
781
+ print(
782
+ f"[TecplotTecioReader] Skipping zone {zone.zone_index} "
783
+ f"({zone.title!r}): {exc}"
784
+ )
785
+ continue
786
+ except Exception as exc: # noqa: BLE001 - one bad zone shouldn't fail the read
787
+ print(
788
+ f"[TecplotTecioReader] Error reading zone {zone.zone_index} "
789
+ f"({zone.title!r}), skipping: {exc}"
790
+ )
791
+ continue
792
+
793
+ output.SetBlock(block_index, dataset)
794
+ name = zone.title or f"Zone {zone.zone_index}"
795
+ output.GetMetaData(block_index).Set(vtkCompositeDataSet.NAME(), name)
796
+ block_index += 1
797
+
798
+ if requested_time is not None:
799
+ output.GetInformation().Set(output.DATA_TIME_STEP(), requested_time)
800
+ return 1
801
+
802
+ def _add_variable_aux_field_data(
803
+ self, field_data: vtkFieldData, reader: _TecioReader
804
+ ) -> None:
805
+ """Attach variable-level aux data to *field_data*, prefixed by variable name."""
806
+ names = reader.variables
807
+ for i in range(1, reader.num_vars + 1):
808
+ aux = reader.get_var_auxdata(i)
809
+ if not len(aux):
810
+ continue
811
+ var_name = names[i - 1]
812
+ _add_aux_field_data(
813
+ field_data, aux.items(), key_fn=lambda k, v=var_name: f"{v}::{k}"
814
+ )
815
+
816
+ # -- Zone -> VTK dataset conversion ------------------------------------------------
817
+
818
+ def _build_zone_dataset(
819
+ self,
820
+ zone: Any,
821
+ x_name: str | None,
822
+ y_name: str | None,
823
+ z_name: str | None,
824
+ u_name: str | None,
825
+ v_name: str | None,
826
+ w_name: str | None,
827
+ vector_array_name: str,
828
+ ) -> vtkStructuredGrid | vtkUnstructuredGrid:
829
+ """Convert one Tecplot zone into a VTK type.
830
+
831
+ Structured/Unstructured correspond to``vtkStructuredGrid``/
832
+ ``vtkUnstructuredGrid`` respectively.
833
+ """
834
+ if zone.zone_type == ZoneType.ORDERED:
835
+ dataset: vtkStructuredGrid | vtkUnstructuredGrid = (
836
+ self._build_structured_grid(zone, x_name, y_name, z_name)
837
+ )
838
+ else:
839
+ cell_type = _VTK_CELL_TYPE.get(zone.zone_type)
840
+ if cell_type is None:
841
+ raise _UnsupportedZoneError(
842
+ f"zone type {zone.zone_type.name} is not yet supported by this "
843
+ "reader (only ORDERED and classic FE zones -- line, triangle, "
844
+ "quadrilateral, tetrahedron, brick -- are converted; "
845
+ "polygon/polyhedron/mixed zones are not)"
846
+ )
847
+ dataset = self._build_unstructured_grid(
848
+ zone, cell_type, x_name, y_name, z_name
849
+ )
850
+
851
+ _add_aux_field_data(dataset.GetFieldData(), zone.auxdata.items())
852
+ self._add_variable_arrays(dataset, zone)
853
+ self._add_vector_array(dataset, zone, vector_array_name, u_name, v_name, w_name)
854
+ return dataset
855
+
856
+ def _build_structured_grid(
857
+ self, zone: Any, x_name: str | None, y_name: str | None, z_name: str | None
858
+ ) -> vtkStructuredGrid:
859
+ """Build a ``vtkStructuredGrid`` for an ORDERED zone."""
860
+ ni, nj, nk = zone.dimensions
861
+ shape = (ni, nj, nk)
862
+ x = self._fetch_ordered_coordinate(zone, x_name, shape)
863
+ y = self._fetch_ordered_coordinate(zone, y_name, shape)
864
+ z = self._fetch_ordered_coordinate(zone, z_name, shape)
865
+
866
+ # Tecplot's IJK arrays come back Fortran-ordered (I fastest); raveling the same
867
+ # way lines the flat point list up with VTK's own I-fastest point ordering, so
868
+ # no per-point reindexing is needed.
869
+ points = np.empty((ni * nj * nk, 3), dtype=np.float64)
870
+ points[:, 0] = x.ravel(order="F")
871
+ points[:, 1] = y.ravel(order="F")
872
+ points[:, 2] = z.ravel(order="F")
873
+
874
+ vtk_points = vtkPoints()
875
+ vtk_points.SetData(numpy_support.numpy_to_vtk(points, deep=True))
876
+
877
+ grid = vtkStructuredGrid()
878
+ grid.SetDimensions(ni, nj, nk)
879
+ grid.SetPoints(vtk_points)
880
+ return grid
881
+
882
+ @staticmethod
883
+ def _fetch_ordered_coordinate(
884
+ zone: Any, name: str | None, shape: tuple[int, int, int]
885
+ ) -> npt.NDArray[np.float64]:
886
+ """Return an ``(I, J, K)`` nodal coordinate array for *name*, or zeros."""
887
+ if name is None:
888
+ return np.zeros(shape, dtype=np.float64)
889
+ values = zone.get_array(name)
890
+ if values is None:
891
+ raise _UnsupportedZoneError(
892
+ f"coordinate variable '{name}' has no data in this zone (it is passive)"
893
+ )
894
+ if values.shape != shape:
895
+ raise _UnsupportedZoneError(
896
+ f"coordinate variable '{name}' has shape {values.shape}, expected "
897
+ f"nodal shape {shape} -- is it cell-centered?"
898
+ )
899
+ return values.astype(np.float64, copy=False)
900
+
901
+ def _build_unstructured_grid(
902
+ self,
903
+ zone: Any,
904
+ cell_type: int,
905
+ x_name: str | None,
906
+ y_name: str | None,
907
+ z_name: str | None,
908
+ ) -> vtkUnstructuredGrid:
909
+ """Build a ``vtkUnstructuredGrid`` for a classic finite-element zone."""
910
+ n_nodes = zone.num_nodes
911
+ x = self._fetch_flat_coordinate(zone, x_name, n_nodes)
912
+ y = self._fetch_flat_coordinate(zone, y_name, n_nodes)
913
+ z = self._fetch_flat_coordinate(zone, z_name, n_nodes)
914
+
915
+ points = np.column_stack((x, y, z)).astype(np.float64, copy=False)
916
+ vtk_points = vtkPoints()
917
+ vtk_points.SetData(numpy_support.numpy_to_vtk(points, deep=True))
918
+
919
+ node_map = zone.node_map
920
+ if node_map is None or node_map.size == 0:
921
+ raise _UnsupportedZoneError("zone has no connectivity (empty node map)")
922
+
923
+ n_cells, nodes_per_cell = node_map.shape
924
+ # tecio node maps are 1-based (Tecplot convention); VTK point ids are 0-based.
925
+ connectivity = (node_map.astype(np.int64, copy=False) - 1).ravel()
926
+ offsets = np.arange(
927
+ 0, (n_cells + 1) * nodes_per_cell, nodes_per_cell, dtype=np.int64
928
+ )
929
+
930
+ cell_array = vtkCellArray()
931
+ cell_array.SetData(
932
+ numpy_support.numpy_to_vtkIdTypeArray(offsets, deep=True),
933
+ numpy_support.numpy_to_vtkIdTypeArray(connectivity, deep=True),
934
+ )
935
+
936
+ grid = vtkUnstructuredGrid()
937
+ grid.SetPoints(vtk_points)
938
+ grid.SetCells(cell_type, cell_array)
939
+ return grid
940
+
941
+ @staticmethod
942
+ def _fetch_flat_coordinate(
943
+ zone: Any, name: str | None, n_nodes: int
944
+ ) -> npt.NDArray[np.float64]:
945
+ """Return a flat, length-``n_nodes`` coordinate array for *name*, or zeros."""
946
+ if name is None:
947
+ return np.zeros(n_nodes, dtype=np.float64)
948
+ values = zone.get_array(name)
949
+ if values is None:
950
+ raise _UnsupportedZoneError(
951
+ f"coordinate variable '{name}' has no data in this zone (it is passive)"
952
+ )
953
+ if values.size != n_nodes:
954
+ raise _UnsupportedZoneError(
955
+ f"coordinate variable '{name}' has {values.size} values, expected "
956
+ f"{n_nodes} nodal values"
957
+ )
958
+ return values.astype(np.float64, copy=False)
959
+
960
+ def _add_variable_arrays(
961
+ self, dataset: vtkStructuredGrid | vtkUnstructuredGrid, zone: Any
962
+ ) -> None:
963
+ """Attach every selected, active variable to point or cell data.
964
+
965
+ Node-located (``NODAL``) variables become point data; cell-located
966
+ (``CELL_CENTERED``) variables become cell data. A variable whose array length
967
+ doesn't match the dataset's point/cell count is skipped rather than raising, so
968
+ one bad variable doesn't take an otherwise-good zone down with it.
969
+ """
970
+ n_points = dataset.GetNumberOfPoints()
971
+ n_cells = dataset.GetNumberOfCells()
972
+
973
+ for var in zone.variable:
974
+ if not self._array_selection.ArrayIsEnabled(var.name):
975
+ continue
976
+ values = var.values
977
+ if values is None: # passive, or genuinely no data
978
+ continue
979
+
980
+ flat = values.ravel(order="F") if values.ndim == 3 else np.ravel(values)
981
+ vtk_array = numpy_support.numpy_to_vtk(
982
+ np.ascontiguousarray(flat), deep=True
983
+ )
984
+ vtk_array.SetName(var.name)
985
+
986
+ if var.value_location == ValueLocation.NODAL:
987
+ if flat.size != n_points:
988
+ continue
989
+ dataset.GetPointData().AddArray(vtk_array)
990
+ else:
991
+ if flat.size != n_cells:
992
+ continue
993
+ dataset.GetCellData().AddArray(vtk_array)
994
+
995
+ def _add_vector_array(
996
+ self,
997
+ dataset: vtkStructuredGrid | vtkUnstructuredGrid,
998
+ zone: Any,
999
+ array_name: str,
1000
+ u_name: str | None,
1001
+ v_name: str | None,
1002
+ w_name: str | None,
1003
+ ) -> None:
1004
+ """Assemble the U/V/W components into one 3-component vector array.
1005
+
1006
+ Skipped entirely if none of U/V/W resolved for the dataset. A component that did
1007
+ resolve for the dataset but is passive or absent in *this specific* zone is
1008
+ treated as zero for that zone, same as an axis that never resolved at all. All
1009
+ contributing components must share one value location (``NODAL`` or
1010
+ ``CELL_CENTERED``) and array length; a mismatch skips vector assembly for this
1011
+ zone with a printed warning, since the rest of the zone is still good.
1012
+ """
1013
+ if u_name is None and v_name is None and w_name is None:
1014
+ return
1015
+
1016
+ location: ValueLocation | None = None
1017
+ flat_components: list[npt.NDArray[np.float64] | None] = []
1018
+ for name in (u_name, v_name, w_name):
1019
+ if name is None:
1020
+ flat_components.append(None)
1021
+ continue
1022
+ var = zone.variable[name]
1023
+ values = var.values
1024
+ if values is None: # passive, or absent in this specific zone
1025
+ flat_components.append(None)
1026
+ continue
1027
+
1028
+ flat = values.ravel(order="F") if values.ndim == 3 else np.ravel(values)
1029
+ if location is None:
1030
+ location = var.value_location
1031
+ elif var.value_location != location:
1032
+ print(
1033
+ f"[TecplotTecioReader] Zone {zone.zone_index} ({zone.title!r}): "
1034
+ f"'{name}' has a different value location than the other "
1035
+ "vector components; skipping the vector for this zone."
1036
+ )
1037
+ return
1038
+ flat_components.append(flat)
1039
+
1040
+ if location is None:
1041
+ return # every resolved component was passive/absent in this zone
1042
+
1043
+ n_tuples = (
1044
+ dataset.GetNumberOfPoints()
1045
+ if location == ValueLocation.NODAL
1046
+ else dataset.GetNumberOfCells()
1047
+ )
1048
+ zeros = np.zeros(n_tuples, dtype=np.float64)
1049
+ columns = []
1050
+ for flat in flat_components:
1051
+ if flat is None:
1052
+ columns.append(zeros)
1053
+ elif flat.size != n_tuples:
1054
+ print(
1055
+ f"[TecplotTecioReader] Zone {zone.zone_index} ({zone.title!r}): "
1056
+ f"a vector component has {flat.size} values, expected "
1057
+ f"{n_tuples}; skipping the vector for this zone."
1058
+ )
1059
+ return
1060
+ else:
1061
+ columns.append(flat.astype(np.float64, copy=False))
1062
+
1063
+ vector = np.ascontiguousarray(np.column_stack(columns))
1064
+ vtk_array = numpy_support.numpy_to_vtk(vector, deep=True)
1065
+ vtk_array.SetName(array_name)
1066
+
1067
+ if location == ValueLocation.NODAL:
1068
+ dataset.GetPointData().AddArray(vtk_array)
1069
+ else:
1070
+ dataset.GetCellData().AddArray(vtk_array)