tecio-python 0.3.2__py3-none-any.whl → 0.3.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.
tecio/__init__.py CHANGED
@@ -9,7 +9,7 @@ from importlib import metadata
9
9
  try:
10
10
  __version__ = metadata.version("tecio")
11
11
  except metadata.PackageNotFoundError:
12
- __version__ = "0.3.2"
12
+ __version__ = "0.3.3"
13
13
 
14
14
  from . import cli, libtecio
15
15
  from ._constants import (
@@ -28,7 +28,20 @@ from ._constants import (
28
28
  from ._containers import VariableList, ZoneList
29
29
  from ._dat_read import TecplotDatReader
30
30
  from ._dat_write import TecplotDatWriter
31
- from ._io import AppendReadWrite, AppendWrite, open
31
+ from ._io import (
32
+ AppendReadWrite,
33
+ AppendWrite,
34
+ FileSummary,
35
+ ZoneSummary,
36
+ get_file_type,
37
+ get_num_variables,
38
+ get_num_zones,
39
+ get_title,
40
+ get_variable_list,
41
+ get_zone_list,
42
+ open,
43
+ peek,
44
+ )
32
45
  from ._plt_read import TecplotPltReader
33
46
  from ._plt_write import TecplotPltWriter
34
47
  from ._reader import (
@@ -82,6 +95,15 @@ del _cls
82
95
  __all__ = [
83
96
  "libtecio",
84
97
  "open",
98
+ "peek",
99
+ "get_variable_list",
100
+ "get_zone_list",
101
+ "get_num_zones",
102
+ "get_num_variables",
103
+ "get_title",
104
+ "get_file_type",
105
+ "FileSummary",
106
+ "ZoneSummary",
85
107
  "cli",
86
108
  "AppendWrite",
87
109
  "AppendReadWrite",
tecio/_dat_read.py CHANGED
@@ -14,8 +14,9 @@ and (for data values) PLT.
14
14
  from __future__ import annotations
15
15
 
16
16
  import contextlib
17
+ import os
17
18
  import re
18
- from typing import Any
19
+ from typing import Any, NamedTuple
19
20
 
20
21
  import numpy as np
21
22
  import numpy.typing as npt
@@ -299,6 +300,93 @@ def _next_token_is_value(text: str, i: int) -> bool:
299
300
  return not (j < n and text[j] == "=")
300
301
 
301
302
 
303
+ def _determine_zone_type(kv: dict[str, str]) -> ZoneType:
304
+ """Determine a zone's type from its header key-value pairs.
305
+
306
+ Shared by the full parser and the metadata-only scanner so both agree on the same
307
+ modern/legacy keyword rules.
308
+
309
+ Two header dialects are supported:
310
+ * Modern: ``ZONETYPE=FEQuadrilateral, DATAPACKING=POINT``
311
+ * Legacy: ``F=FEPOINT, ET=QUADRILATERAL``
312
+
313
+ The FE-vs-ordered distinction comes from ``ZONETYPE`` (modern) or ``F``
314
+ (legacy). ``ET`` (element type) only ever appears on finite-element zones, so it
315
+ merely names the element *shape* once a zone is already known to be FE. Modern
316
+ keywords win when present.
317
+
318
+ Args:
319
+ kv: Zone header key-value pairs (leading ``ZONE`` keyword already stripped), as
320
+ returned by :func:`_kv_split`.
321
+
322
+ Returns:
323
+ The zone's type. Unlike the full parser, does not reject FEPOLYGON/FEPOLYHEDRON,
324
+ those just aren't readable for data, the header itself is perfectly legible.
325
+
326
+ Raises:
327
+ ValueError: If a legacy FE header (``F=FEPOINT``/``FEBLOCK``) is
328
+ missing the required ``ET`` keyword.
329
+
330
+ Example:
331
+ >>> _determine_zone_type({"T": '"z"', "I": "5"})
332
+ <ZoneType.ORDERED: ...>
333
+ """
334
+ legacy_is_fe: bool | None = None
335
+ if "F" in kv:
336
+ legacy_is_fe, _ = _parse_legacy_format(kv["F"])
337
+
338
+ if "ZONETYPE" in kv:
339
+ zt_raw = kv["ZONETYPE"].rstrip(",").strip().lower()
340
+ return _STR_TO_ZONETYPE.get(zt_raw, ZoneType.ORDERED)
341
+ elif legacy_is_fe is False:
342
+ return ZoneType.ORDERED
343
+ elif legacy_is_fe or "ET" in kv:
344
+ if "ET" not in kv:
345
+ raise ValueError(
346
+ "Legacy FE zone header specifies F=FEPOINT/FEBLOCK but is "
347
+ "missing the required ET (element type) keyword."
348
+ )
349
+ return _parse_legacy_element_type(kv["ET"])
350
+ else:
351
+ return ZoneType.ORDERED
352
+
353
+
354
+ def _collect_zone_header_lines(tokens: _LineBuffer) -> list[str]:
355
+ """Collect a ZONE block's header lines, stopping before data begins.
356
+
357
+ Shared by the full parser and the metadata-only scanner (:func:`peek_dat_metadata`)
358
+ so both agree on exactly where a zone's header ends and its data starts.
359
+
360
+ Args:
361
+ tokens: Positioned at the ``ZONE`` line itself (not yet consumed).
362
+
363
+ Returns:
364
+ Raw header lines, including the leading ``ZONE`` keyword line.
365
+
366
+ Example:
367
+ >>> lines = _collect_zone_header_lines(tokens)
368
+ """
369
+ header_lines: list[str] = [tokens.next_stripped()] # ZONE T=...
370
+ while tokens.has_more():
371
+ nxt = tokens.peek_stripped()
372
+ if not nxt:
373
+ tokens.next_stripped()
374
+ continue
375
+ nxt_upper = nxt.lstrip().upper()
376
+ # Stop at a new zone, top-level keyword, or the first data line.
377
+ if nxt_upper.split("=")[0].split()[0] == "ZONE":
378
+ break
379
+ if nxt_upper.startswith(("DATASETAUXDATA", "VARAUXDATA")):
380
+ break
381
+ first_ch = nxt.lstrip()[0] if nxt.lstrip() else ""
382
+ # A data line begins with a numeric token (covers leading-dot values such as
383
+ # ``.5`` and signed values such as ``-1.2e3``).
384
+ if first_ch in "0123456789+-.":
385
+ break
386
+ header_lines.append(tokens.next_stripped())
387
+ return header_lines
388
+
389
+
302
390
  def _kv_split(text: str) -> dict[str, str]:
303
391
  """Parse a loose ``KEY=VALUE`` string into an upper-cased-key dict.
304
392
 
@@ -1022,6 +1110,162 @@ class TecplotDatFEZoneReader(TecplotFEZoneReader):
1022
1110
  return TecplotDatAuxDataReader(self._aux_raw)
1023
1111
 
1024
1112
 
1113
+ class _DatZoneInfo(NamedTuple):
1114
+ """Per-zone metadata from a cheap header-only scan."""
1115
+
1116
+ title: str
1117
+ zone_type: ZoneType
1118
+ num_nodes: int | None
1119
+ num_elements: int | None
1120
+
1121
+
1122
+ class _DatMetadata(NamedTuple):
1123
+ """Result of :func:`_peek_dat_metadata`."""
1124
+
1125
+ title: str
1126
+ file_type: FileType
1127
+ variable_names: list[str]
1128
+ zones: list[_DatZoneInfo]
1129
+
1130
+
1131
+ _ZONE_LINE_RE = re.compile(r"^[ \t]*ZONE\b", re.IGNORECASE | re.MULTILINE)
1132
+ _ZONE_DATA_START_RE = re.compile(r"^[ \t]*[0-9+\-.]", re.MULTILINE)
1133
+
1134
+
1135
+ def _peek_dat_metadata(path: str | os.PathLike) -> _DatMetadata:
1136
+ """Scan a DAT file's title, variable names, and per-zone metadata only.
1137
+
1138
+ Unlike opening the file normally, never converts a single data value to a number. A
1139
+ single bulk regex pass across the whole file finds every zone header's starting
1140
+ line; each zone's metadata is then extracted from just the handful of lines between
1141
+ that start and the next one (or EOF), reusing the same header-parsing logic the real
1142
+ parser uses, so a zone header can still span multiple lines correctly. The data and
1143
+ connectivity blocks in between are never read at all. A line-by-line Python scan
1144
+ over the whole file (rather than this bulk approach) was tried first and measured
1145
+ slower than a full parse for large files, string-heavy per-line loops in Python
1146
+ don't beat NumPy's bulk numeric parsing, even when doing less work; a single C-level
1147
+ regex pass over the whole file does.
1148
+
1149
+ A zone's ``num_nodes``/``num_elements`` are ``None`` only for the one case they
1150
+ genuinely can't be determined without reading data: an ordered zone whose header
1151
+ omits I/J/K entirely, relying on the reader to infer the point count from the data
1152
+ itself (see :meth:`TecplotDatReader._infer_ordered_point_count`). Every other case,
1153
+ including FE zones, always states its node/element counts directly in the header
1154
+ text.
1155
+
1156
+ Args:
1157
+ path: Path to a ``.dat``/``.tec`` file.
1158
+
1159
+ Returns:
1160
+ Title, file type, variable names, and per-zone metadata (in file order).
1161
+
1162
+ Raises:
1163
+ FileNotFoundError: If *path* does not exist.
1164
+
1165
+ Example:
1166
+ >>> meta = _peek_dat_metadata("flow.dat")
1167
+ >>> [z.title for z in meta.zones]
1168
+ ['FluidVolume', 'WingSurface']
1169
+ """
1170
+ with open(path, encoding="utf-8", errors="replace") as fh:
1171
+ text = fh.read()
1172
+
1173
+ zone_starts = [m.start() for m in _ZONE_LINE_RE.finditer(text)]
1174
+
1175
+ # File header: everything before the first zone (or the whole file, if it somehow
1176
+ # has none). Mirrors TecplotDatReader._parse_file_header, kept separate rather than
1177
+ # shared with it since that method also populates dataset aux data, which this
1178
+ # function deliberately doesn't scan for. Just a handful of lines regardless of file
1179
+ # size, so no bulk-regex treatment needed here.
1180
+ header_end = zone_starts[0] if zone_starts else len(text)
1181
+ tokens = _LineBuffer(text[:header_end].splitlines(keepends=True))
1182
+
1183
+ title = ""
1184
+ file_type = FileType.FULL
1185
+ variable_names: list[str] = []
1186
+ while tokens.has_more():
1187
+ line = tokens.peek_stripped()
1188
+ if not line:
1189
+ tokens.next_stripped()
1190
+ continue
1191
+ upper_key = (
1192
+ line.split("=")[0].strip().upper() if "=" in line else line.strip().upper()
1193
+ )
1194
+ tokens.next_stripped()
1195
+
1196
+ if upper_key == "TITLE":
1197
+ rhs = line.split("=", 1)[1].strip() if "=" in line else ""
1198
+ title = _unquote(rhs)
1199
+ elif upper_key == "FILETYPE":
1200
+ rhs = line.split("=", 1)[1].strip() if "=" in line else ""
1201
+ file_type = _STR_TO_FILETYPE.get(rhs.strip().lower(), FileType.FULL)
1202
+ elif upper_key == "VARIABLES":
1203
+ rhs = line.split("=", 1)[1].strip() if "=" in line else ""
1204
+ names = _extract_quoted_strings(rhs)
1205
+ while tokens.has_more():
1206
+ nxt = tokens.peek_stripped()
1207
+ if not nxt:
1208
+ tokens.next_stripped()
1209
+ continue
1210
+ if nxt.lstrip().startswith('"'):
1211
+ names.extend(_extract_quoted_strings(tokens.next_stripped()))
1212
+ elif "=" not in nxt.split("#")[0]:
1213
+ tokens.next_stripped()
1214
+ else:
1215
+ break
1216
+ variable_names = names
1217
+
1218
+ # Zones: each zone's header lives in the small window between its start and the next
1219
+ # zone's start (or EOF); _collect_zone_header_lines only ever looks at the first
1220
+ # handful of lines in that window before hitting what looks like data, so this stays
1221
+ # cheap regardless of how much data actually follows.
1222
+ zones: list[_DatZoneInfo] = []
1223
+ for i, start in enumerate(zone_starts):
1224
+ end = zone_starts[i + 1] if i + 1 < len(zone_starts) else len(text)
1225
+ # Bounded search directly on the original string
1226
+ data_match = _ZONE_DATA_START_RE.search(text, start, end)
1227
+ header_end = data_match.start() if data_match else end
1228
+ window_tokens = _LineBuffer(text[start:header_end].splitlines(keepends=True))
1229
+ header_lines = _collect_zone_header_lines(window_tokens)
1230
+ header_text = " ".join(header_lines)
1231
+ m_zone = re.match(r"(?i)^ZONE\s*", header_text)
1232
+ if m_zone:
1233
+ header_text = header_text[m_zone.end() :]
1234
+ kv = _kv_split(header_text)
1235
+ zone_title = _unquote(kv.get("T", ""))
1236
+
1237
+ try:
1238
+ zone_type = _determine_zone_type(kv)
1239
+ except ValueError:
1240
+ # A legacy header F=FEPOINT with no ET reports as unknown
1241
+ zones.append(_DatZoneInfo(zone_title, ZoneType.ORDERED, None, None))
1242
+ continue
1243
+
1244
+ num_nodes: int | None
1245
+ num_elements: int | None
1246
+ if zone_type == ZoneType.ORDERED:
1247
+ if "I" not in kv and "J" not in kv and "K" not in kv:
1248
+ # Point count only knowable by reading data -> report unknown
1249
+ num_nodes = None
1250
+ num_elements = None
1251
+ else:
1252
+ zi = int(kv.get("I", "1") or "1")
1253
+ zj = int(kv.get("J", "1") or "1")
1254
+ zk = int(kv.get("K", "1") or "1")
1255
+ num_nodes = zi * zj * zk
1256
+ num_elements = max(zi - 1, 1) * max(zj - 1, 1) * max(zk - 1, 1)
1257
+ else:
1258
+ # FE zones (including FEPOLYGON/FEPOLYHEDRON, which the full parser can't
1259
+ # read data for, but whose header is just as legible as any other zone's)
1260
+ # always state counts directly.
1261
+ num_nodes = int(kv.get("NODES", kv.get("N", "0")) or "0")
1262
+ num_elements = int(kv.get("ELEMENTS", kv.get("E", "0")) or "0")
1263
+
1264
+ zones.append(_DatZoneInfo(zone_title, zone_type, num_nodes, num_elements))
1265
+
1266
+ return _DatMetadata(title, file_type, variable_names, zones)
1267
+
1268
+
1025
1269
  class TecplotDatReader(TecplotReader):
1026
1270
  """Reader for Tecplot ASCII DAT files.
1027
1271
 
@@ -1143,8 +1387,10 @@ class TecplotDatReader(TecplotReader):
1143
1387
 
1144
1388
  while tokens.has_more():
1145
1389
  line = tokens.peek_stripped()
1390
+ if not line:
1391
+ tokens.next_stripped()
1392
+ continue
1146
1393
  upper = line.upper()
1147
- # if upper.startswith("ZONE"):
1148
1394
  if upper.lstrip().split("=")[0].split()[0] == "ZONE":
1149
1395
  self._parse_zone(tokens)
1150
1396
  elif upper.startswith("DATASETAUXDATA"):
@@ -1232,7 +1478,9 @@ class TecplotDatReader(TecplotReader):
1232
1478
  def _infer_ordered_point_count(tokens: _LineBuffer, active_count: int) -> int:
1233
1479
  """Infer an ordered zone's point count when I/J/K are all omitted.
1234
1480
 
1235
- Peeks ahead counting numeric tokens up to the next zone/keyword boundary or end
1481
+ Some legacy exporters write a bare ``ZONE F=POINT`` header with no dimensions at
1482
+ all, relying on the reader to count data rows itself. Peeks ahead (never
1483
+ consuming) counting numeric tokens up to the next zone/keyword boundary or end
1236
1484
  of file, using the same boundary rule :meth:`_parse_zone` already uses to know
1237
1485
  where a header ends and data begins, then restores the original position so the
1238
1486
  real read (later, driven by the now-known count) starts from the same place.
@@ -1283,25 +1531,7 @@ class TecplotDatReader(TecplotReader):
1283
1531
  """
1284
1532
  # -- Collect header lines ------------------------------------------------------
1285
1533
 
1286
- header_lines: list[str] = [tokens.next_stripped()] # ZONE T=...
1287
-
1288
- while tokens.has_more():
1289
- nxt = tokens.peek_stripped()
1290
- if not nxt:
1291
- tokens.next_stripped()
1292
- continue
1293
- nxt_upper = nxt.lstrip().upper()
1294
- # Stop at a new zone, top-level keyword, or the first data line.
1295
- if nxt_upper.split("=")[0].split()[0] == "ZONE":
1296
- break
1297
- if nxt_upper.startswith(("DATASETAUXDATA", "VARAUXDATA")):
1298
- break
1299
- first_ch = nxt.lstrip()[0] if nxt.lstrip() else ""
1300
- # A data line begins with a numeric token (covers leading-dot values such as
1301
- # ``.5`` and signed values such as ``-1.2e3``).
1302
- if first_ch in "0123456789+-.":
1303
- break
1304
- header_lines.append(tokens.next_stripped())
1534
+ header_lines = _collect_zone_header_lines(tokens)
1305
1535
 
1306
1536
  header_text = " ".join(header_lines)
1307
1537
 
@@ -1320,38 +1550,13 @@ class TecplotDatReader(TecplotReader):
1320
1550
 
1321
1551
  # -- Determine zone type and data packing --------------------------------------
1322
1552
  #
1323
- # Two header dialects are supported:
1324
- # * Modern: ZONETYPE=FEQuadrilateral, DATAPACKING=POINT
1325
- # * Legacy: F=FEPOINT, ET=QUADRILATERAL
1326
- #
1327
- # The FE-vs-ordered distinction comes from ``ZONETYPE`` (modern) or ``F``
1328
- # (legacy). ``ET`` (element type) only ever appears on finite-element zones —
1329
- # ordered/structured data has no elements — so it merely names the element
1330
- # *shape* once a zone is already known to be FE, and is ignored on ordered
1331
- # zones. Modern keywords win when present.
1332
- legacy_is_fe: bool | None = None
1553
+ # See _determine_zone_type for the modern/legacy keyword rules this shares with
1554
+ # the metadata-only scanner (peek_dat_metadata).
1333
1555
  legacy_packing: DataPacking | None = None
1334
1556
  if "F" in kv:
1335
- legacy_is_fe, legacy_packing = _parse_legacy_format(kv["F"])
1336
-
1337
- if "ZONETYPE" in kv:
1338
- zt_raw = kv["ZONETYPE"].rstrip(",").strip().lower()
1339
- zone_type = _STR_TO_ZONETYPE.get(zt_raw, ZoneType.ORDERED)
1340
- elif legacy_is_fe is False:
1341
- # F=POINT/BLOCK: ordered zone. A stray ET (if any) does not apply.
1342
- zone_type = ZoneType.ORDERED
1343
- elif legacy_is_fe or "ET" in kv:
1344
- # Finite-element zone: F=FEPOINT/FEBLOCK, or an ET keyword with no F (some
1345
- # exporters omit F). The element shape comes from ET, which is then
1346
- # required.
1347
- if "ET" not in kv:
1348
- raise ValueError(
1349
- "Legacy FE zone header specifies F=FEPOINT/FEBLOCK but is "
1350
- "missing the required ET (element type) keyword."
1351
- )
1352
- zone_type = _parse_legacy_element_type(kv["ET"])
1353
- else:
1354
- zone_type = ZoneType.ORDERED
1557
+ _, legacy_packing = _parse_legacy_format(kv["F"])
1558
+
1559
+ zone_type = _determine_zone_type(kv)
1355
1560
 
1356
1561
  if zone_type in _FE_POLY:
1357
1562
  raise ValueError(
@@ -1372,7 +1577,7 @@ class TecplotDatReader(TecplotReader):
1372
1577
 
1373
1578
  if zone_type == ZoneType.ORDERED:
1374
1579
  if "I" not in kv and "J" not in kv and "K" not in kv:
1375
- # Infer the point count from the data that follows
1580
+ # Infer point count
1376
1581
  active_count = self.num_vars - len(passive_set) - len(share_map)
1377
1582
  num_nodes = self._infer_ordered_point_count(tokens, active_count)
1378
1583
  I = num_nodes # noqa E741
tecio/_io.py CHANGED
@@ -40,14 +40,14 @@ from __future__ import annotations
40
40
  import os
41
41
  import tempfile
42
42
  from pathlib import Path
43
- from typing import Any, Literal, overload
43
+ from typing import Any, Literal, NamedTuple, overload
44
44
 
45
45
  import numpy as np
46
46
  import numpy.typing as npt
47
47
 
48
48
  from ._constants import FileType, ValueLocation, ZoneType
49
49
  from ._containers import ZoneList
50
- from ._dat_read import TecplotDatReader
50
+ from ._dat_read import TecplotDatReader, _peek_dat_metadata
51
51
  from ._dat_write import TecplotDatWriter
52
52
  from ._plt_read import TecplotPltReader
53
53
  from ._plt_write import TecplotPltWriter
@@ -811,3 +811,252 @@ def open(
811
811
  raise ValueError(
812
812
  f"Unrecognised mode '{mode}'. Supported modes: 'r', 'w', 'x', 'a', 'a+'"
813
813
  )
814
+
815
+
816
+ # --------------------------------------------------------------------------------------
817
+ # Lightweight metadata queries
818
+ # --------------------------------------------------------------------------------------
819
+
820
+
821
+ def _validated_ext(path: str | os.PathLike) -> str:
822
+ """Return *path*'s lowercased extension, or raise if unsupported.
823
+
824
+ Example:
825
+ >>> _validated_ext("flow.szplt")
826
+ '.szplt'
827
+ """
828
+ ext = Path(path).suffix.lower()
829
+ if ext not in _HANDLERS:
830
+ raise ValueError(
831
+ f"Unsupported file extension: '{ext}'. Supported: {sorted(_HANDLERS)}"
832
+ )
833
+ return ext
834
+
835
+
836
+ class ZoneSummary(NamedTuple):
837
+ """Per-zone metadata from a lightweight :func:`peek` query.
838
+
839
+ ``num_nodes``/``num_elements`` are ``None`` only for a DAT ordered zone whose header
840
+ omits I/J/K entirely (rare; relies on the reader inferring the point count from the
841
+ data itself, which a lightweight query never does). Every other case, and every
842
+ SZL/PLT zone, always has both.
843
+ """
844
+
845
+ title: str
846
+ zone_type: ZoneType
847
+ num_nodes: int | None
848
+ num_elements: int | None
849
+
850
+
851
+ class FileSummary(NamedTuple):
852
+ """Whole-file metadata from :func:`peek`, with no variable data touched."""
853
+
854
+ title: str
855
+ file_type: FileType
856
+ variable_names: list[str]
857
+ zones: list[ZoneSummary]
858
+
859
+
860
+ def get_variable_list(path: str | os.PathLike) -> list[str]:
861
+ """Return a file's variable names without reading any variable data.
862
+
863
+ For SZL and PLT this is already what a normal :func:`open` does, both formats
864
+ resolve dataset-level metadata (including variable names) up front without touching
865
+ data arrays, so this is a thin convenience wrapper for those two. DAT is different:
866
+ opening it normally always reads and numerically converts every value in the file,
867
+ so this function instead scans just the file's header text, several orders of
868
+ magnitude cheaper for a file with large data blocks.
869
+
870
+ Args:
871
+ path: Path to a ``.szplt``, ``.plt``/``.bin``, or ``.dat``/``.tec`` file.
872
+
873
+ Returns:
874
+ Variable names, in file order.
875
+
876
+ Raises:
877
+ FileNotFoundError: If *path* does not exist.
878
+ ValueError: If the file extension is not recognized.
879
+
880
+ Example:
881
+ >>> get_variable_list("flow.szplt")
882
+ ['x', 'y', 'z', 'pressure']
883
+ """
884
+ ext = _validated_ext(path)
885
+ if ext in (".dat", ".tec"):
886
+ return _peek_dat_metadata(path).variable_names
887
+ with _HANDLERS[ext]["r"](str(path)) as r:
888
+ return list(r.variables)
889
+
890
+
891
+ def get_zone_list(path: str | os.PathLike) -> list[str]:
892
+ """Return a file's zone titles without reading any variable data.
893
+
894
+ Same rationale as :func:`get_variable_list`: already cheap for SZL and PLT
895
+ (constructing a zone reader only resolves scalar metadata, never variable data),
896
+ so this is a thin wrapper for those; for DAT, uses a dedicated scan that skips
897
+ every data and connectivity block entirely.
898
+
899
+ Args:
900
+ path: Path to a ``.szplt``, ``.plt``/``.bin``, or ``.dat``/``.tec`` file.
901
+
902
+ Returns:
903
+ Zone titles, in file order. An unnamed zone contributes an empty string, not
904
+ a placeholder like ``"Zone 1"``.
905
+
906
+ Raises:
907
+ FileNotFoundError: If *path* does not exist.
908
+ ValueError: If the file extension is not recognized.
909
+
910
+ Example:
911
+ >>> get_zone_list("flow.szplt")
912
+ ['FluidVolume', 'WingSurface']
913
+ """
914
+ ext = _validated_ext(path)
915
+ if ext in (".dat", ".tec"):
916
+ return [z.title for z in _peek_dat_metadata(path).zones]
917
+ with _HANDLERS[ext]["r"](str(path)) as r:
918
+ return [zone.title for zone in r.zones]
919
+
920
+
921
+ def get_num_zones(path: str | os.PathLike) -> int:
922
+ """Return a file's zone count without reading any variable data.
923
+
924
+ Cheaper still than :func:`get_zone_list`: for SZL, a single scalar C-library
925
+ call with no zone objects constructed at all; for PLT and DAT, a count against
926
+ already-parsed/scanned header metadata.
927
+
928
+ Args:
929
+ path: Path to a ``.szplt``, ``.plt``/``.bin``, or ``.dat``/``.tec`` file.
930
+
931
+ Returns:
932
+ Number of zones in the file.
933
+
934
+ Raises:
935
+ FileNotFoundError: If *path* does not exist.
936
+ ValueError: If the file extension is not recognized.
937
+
938
+ Example:
939
+ >>> get_num_zones("flow.szplt")
940
+ 2
941
+ """
942
+ ext = _validated_ext(path)
943
+ if ext in (".dat", ".tec"):
944
+ return len(_peek_dat_metadata(path).zones)
945
+ with _HANDLERS[ext]["r"](str(path)) as r:
946
+ return r.num_zones
947
+
948
+
949
+ def get_num_variables(path: str | os.PathLike) -> int:
950
+ """Return a file's variable count without reading any variable data.
951
+
952
+ Cheaper still than :func:`get_variable_list`: for SZL, a single scalar
953
+ C-library call.
954
+
955
+ Args:
956
+ path: Path to a ``.szplt``, ``.plt``/``.bin``, or ``.dat``/``.tec`` file.
957
+
958
+ Returns:
959
+ Number of variables in the dataset.
960
+
961
+ Raises:
962
+ FileNotFoundError: If *path* does not exist.
963
+ ValueError: If the file extension is not recognized.
964
+
965
+ Example:
966
+ >>> get_num_variables("flow.szplt")
967
+ 4
968
+ """
969
+ ext = _validated_ext(path)
970
+ if ext in (".dat", ".tec"):
971
+ return len(_peek_dat_metadata(path).variable_names)
972
+ with _HANDLERS[ext]["r"](str(path)) as r:
973
+ return r.num_vars
974
+
975
+
976
+ def get_title(path: str | os.PathLike) -> str:
977
+ """Return a file's dataset title without reading any variable data.
978
+
979
+ Args:
980
+ path: Path to a ``.szplt``, ``.plt``/``.bin``, or ``.dat``/``.tec`` file.
981
+
982
+ Returns:
983
+ Dataset title string.
984
+
985
+ Raises:
986
+ FileNotFoundError: If *path* does not exist.
987
+ ValueError: If the file extension is not recognized.
988
+
989
+ Example:
990
+ >>> get_title("flow.szplt")
991
+ 'Onera M6 wing'
992
+ """
993
+ ext = _validated_ext(path)
994
+ if ext in (".dat", ".tec"):
995
+ return _peek_dat_metadata(path).title
996
+ with _HANDLERS[ext]["r"](str(path)) as r:
997
+ return r.title
998
+
999
+
1000
+ def get_file_type(path: str | os.PathLike) -> FileType:
1001
+ """Return a file's :class:`~tecio.FileType` without reading any variable data.
1002
+
1003
+ Args:
1004
+ path: Path to a ``.szplt``, ``.plt``/``.bin``, or ``.dat``/``.tec`` file.
1005
+
1006
+ Returns:
1007
+ The file type (``FULL``, ``GRID``, or ``SOLUTION``).
1008
+
1009
+ Raises:
1010
+ FileNotFoundError: If *path* does not exist.
1011
+ ValueError: If the file extension is not recognized.
1012
+
1013
+ Example:
1014
+ >>> get_file_type("flow.szplt")
1015
+ <FileType.FULL: 0>
1016
+ """
1017
+ ext = _validated_ext(path)
1018
+ if ext in (".dat", ".tec"):
1019
+ return _peek_dat_metadata(path).file_type
1020
+ with _HANDLERS[ext]["r"](str(path)) as r:
1021
+ return r.file_type
1022
+
1023
+
1024
+ def peek(path: str | os.PathLike) -> FileSummary:
1025
+ """Return a file's title, file type, variable names, and per-zone summaries.
1026
+
1027
+ A single combined query covering everything :func:`get_variable_list`,
1028
+ :func:`get_zone_list`, :func:`get_title`, and :func:`get_file_type` return, plus
1029
+ each zone's type and size. For DAT, calling several of those functions
1030
+ separately on the same file means scanning it that many times over, this
1031
+ function scans once no matter how much of the summary is needed.
1032
+
1033
+ Args:
1034
+ path: Path to a ``.szplt``, ``.plt``/``.bin``, or ``.dat``/``.tec`` file.
1035
+
1036
+ Returns:
1037
+ Whole-file and per-zone metadata. See :class:`FileSummary` and
1038
+ :class:`ZoneSummary`.
1039
+
1040
+ Raises:
1041
+ FileNotFoundError: If *path* does not exist.
1042
+ ValueError: If the file extension is not recognized.
1043
+
1044
+ Example:
1045
+ >>> summary = peek("flow.szplt")
1046
+ >>> [z.title for z in summary.zones]
1047
+ ['FluidVolume', 'WingSurface']
1048
+ """
1049
+ ext = _validated_ext(path)
1050
+ if ext in (".dat", ".tec"):
1051
+ meta = _peek_dat_metadata(path)
1052
+ zones = [
1053
+ ZoneSummary(z.title, z.zone_type, z.num_nodes, z.num_elements)
1054
+ for z in meta.zones
1055
+ ]
1056
+ return FileSummary(meta.title, meta.file_type, meta.variable_names, zones)
1057
+ with _HANDLERS[ext]["r"](str(path)) as r:
1058
+ zones = [
1059
+ ZoneSummary(zone.title, zone.zone_type, zone.num_nodes, zone.num_elements)
1060
+ for zone in r.zones
1061
+ ]
1062
+ return FileSummary(r.title, r.file_type, list(r.variables), zones)
tecio/cli/tecmerge.py CHANGED
@@ -9,14 +9,18 @@ time-step sequences, solution times can be assigned automatically from a start t
9
9
  either a fixed interval or an end time; each zone also gets a strand ID matching its
10
10
  1-based position within its source file, so the same physical block (e.g. a wing zone
11
11
  present in every timestep) shares one strand across the whole sequence and can be
12
- animated as a single, continuous entity in the Tecplot GUI.
12
+ animated as a single, continuous entity in the Tecplot GUI. Merged zones get
13
+ ``SourceFile``/``SourceFileName`` aux data record of the source file and path. Grid data
14
+ can also be detected and shared across zones with matching structure instead of
15
+ duplicated in every merged zone.
13
16
 
14
17
  :Usage:
15
18
 
16
19
  .. code:: bash
17
20
 
18
21
  tecmerge [-h] -o PATH [-f] [--title STRING] [--assign-time-strands]
19
- [-s VALUE] [-d VALUE | -e VALUE] [--strand ID] FILE [FILE ...]
22
+ [-s VALUE] [-d VALUE | -e VALUE] [--strand ID] [--merge-grid [LIST]]
23
+ FILE [FILE ...]
20
24
 
21
25
  :Positional Arguments:
22
26
  ``FILE [FILE ...]``
@@ -63,6 +67,26 @@ animated as a single, continuous entity in the Tecplot GUI.
63
67
  per-zone-position assignment described under ``--assign-time-strands``. Has no
64
68
  effect without ``--assign-time-strands``.
65
69
 
70
+ ``--merge-grid [LIST]``
71
+ Detect and share grid data (coordinate variables, and for FE zones,
72
+ connectivity) across zones with matching zone type and dimensions, instead of
73
+ writing it fresh in every merged zone. Zones are compared by a
74
+ ``(zone_type, num_nodes, num_elements)`` signature; the first zone with a given
75
+ signature (in file, then zone, order) writes its grid fresh, and every later
76
+ zone with a matching signature, whether from the same file or a different one,
77
+ shares from it instead. This is not a rigorous check that the underlying data
78
+ is actually identical, just that it plausibly could be, a false match (e.g. two
79
+ genuinely different blocks that happen to share dimensions) would silently
80
+ share the wrong grid.
81
+
82
+ Given with no value, grid variables are found automatically by name (``x``,
83
+ ``X``, ``x-coordinate``, ``xgrid``, and similar common names for X/Y/Z). Given a
84
+ comma-separated list of 1-based indices or exact names into the union variable
85
+ list instead (e.g. ``--merge-grid x,y,z`` or ``--merge-grid 1,2,3``), only those
86
+ variables are treated as the grid; indices and names cannot be mixed in the same
87
+ list. Solution variables are never affected either way, only variables
88
+ explicitly identified as the grid are ever shared.
89
+
66
90
  :Returns:
67
91
  A new Tecplot file written to the output path containing all zones from every input
68
92
  file. Variables absent from a source file are written as passive. Exit code is ``0``
@@ -90,6 +114,16 @@ Examples:
90
114
  $ tecmerge --assign-time-strands -s 0.0 -d 0.1 --strand 1 \\
91
115
  "step_*.szplt" -o transient.szplt
92
116
 
117
+ Merge a multiblock time series, sharing each block's grid across timesteps
118
+ instead of duplicating it::
119
+
120
+ $ tecmerge --assign-time-strands -s 0.0 -d 0.1 --merge-grid \\
121
+ "step_*.szplt" -o transient.szplt
122
+
123
+ Same, but only "x" and "y" are grid variables (a 2-D case with no "z")::
124
+
125
+ $ tecmerge --merge-grid x,y "step_*.szplt" -o transient.szplt
126
+
93
127
  Call directly from a Python session::
94
128
 
95
129
  import tecio.cli.tecmerge.main as tecmerge
@@ -122,6 +156,7 @@ from .. import (
122
156
  TecplotSzlWriter,
123
157
  TecplotWriter,
124
158
  TecplotZoneReader,
159
+ ValueLocation,
125
160
  ZoneType,
126
161
  )
127
162
  from .. import open as tecio_open
@@ -251,6 +286,22 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
251
286
  ),
252
287
  )
253
288
 
289
+ # Grid sharing
290
+ parser.add_argument(
291
+ "--merge-grid",
292
+ nargs="?",
293
+ const="__auto__",
294
+ default=None,
295
+ type=str,
296
+ metavar="LIST",
297
+ help=(
298
+ "Detect and share the grid (coordinate variables, and connectivity for FE "
299
+ "zones) with matching zone type and dimensions. To manually specify grid "
300
+ "coordinates provide a comma-separated list of 1-based indices or exact "
301
+ "variable names (e.g. -merge-grid x,y,z or -merge-grid 1,2,3)."
302
+ ),
303
+ )
304
+
254
305
  return parser.parse_args(argv)
255
306
 
256
307
 
@@ -258,6 +309,157 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
258
309
  # Helpers
259
310
  # --------------------------------------------------------------------------------------
260
311
 
312
+ # Guesses of grid variable names automatic grid variable detection for merging
313
+ _AXIS_SYNONYMS: dict[str, frozenset[str]] = {
314
+ "x": frozenset({
315
+ "x",
316
+ "x-coordinate",
317
+ "x coordinate",
318
+ "coordinate x",
319
+ "coord-x",
320
+ "coordx",
321
+ "xcoord",
322
+ "x_coord",
323
+ "xgrid",
324
+ "x-grid",
325
+ "x_grid",
326
+ }),
327
+ "y": frozenset({
328
+ "y",
329
+ "y-coordinate",
330
+ "y coordinate",
331
+ "coordinate y",
332
+ "coord-y",
333
+ "coordy",
334
+ "ycoord",
335
+ "y_coord",
336
+ "ygrid",
337
+ "y-grid",
338
+ "y_grid",
339
+ }),
340
+ "z": frozenset({
341
+ "z",
342
+ "z-coordinate",
343
+ "z coordinate",
344
+ "coordinate z",
345
+ "coord-z",
346
+ "coordz",
347
+ "zcoord",
348
+ "z_coord",
349
+ "zgrid",
350
+ "z-grid",
351
+ "z_grid",
352
+ }),
353
+ }
354
+
355
+
356
+ def _autodetect_grid_variables(names: list[str]) -> list[int]:
357
+ """Return the 1-based indices in *names* that look like X/Y/Z coordinates.
358
+
359
+ Matched by exact name (case-insensitive, trimmed) against
360
+ :data:`_AXIS_SYNONYMS`, checked in X, Y, Z order; an axis with no match among
361
+ *names* is simply omitted rather than guessed at positionally, unlike the
362
+ ParaView plugin's coordinate resolution, an incorrect guess here would silently
363
+ mishandle real data instead of just looking odd on screen, so this only
364
+ returns indices it's actually confident in.
365
+
366
+ Example:
367
+ >>> _autodetect_grid_variables(["x-grid", "y", "pressure"])
368
+ [1, 2]
369
+ """
370
+ indices: list[int] = []
371
+ for axis in ("x", "y", "z"):
372
+ synonyms = _AXIS_SYNONYMS[axis]
373
+ for i, name in enumerate(names, start=1):
374
+ if name.strip().lower() in synonyms:
375
+ indices.append(i)
376
+ break
377
+ return indices
378
+
379
+
380
+ def _parse_index_or_name_list(value: str) -> list[int | str]:
381
+ """Parse a comma-separated string of 1-based integers and/or variable names.
382
+
383
+ Each token is parsed as an integer where possible; anything else is kept as a
384
+ name string, resolved against the union variable list once it's known.
385
+
386
+ Args:
387
+ value: String like ``"1,2,3"`` or ``"x,y,z"``.
388
+
389
+ Returns:
390
+ List of ``int`` (1-based index) and/or ``str`` (variable name) tokens, in
391
+ the order given.
392
+
393
+ Example:
394
+ >>> _parse_index_or_name_list("x,y,z")
395
+ ['x', 'y', 'z']
396
+ """
397
+ tokens: list[int | str] = []
398
+ for raw in value.split(","):
399
+ token = raw.strip()
400
+ try:
401
+ tokens.append(int(token))
402
+ except ValueError:
403
+ tokens.append(token)
404
+ return tokens
405
+
406
+
407
+ def _resolve_grid_variable_indices(
408
+ explicit: str | None, union_vars: list[str]
409
+ ) -> set[int] | None:
410
+ """Resolve ``--merge-grid``'s value into a set of 1-based union indices.
411
+
412
+ Args:
413
+ explicit: ``None`` if ``--merge-grid`` wasn't given at all (the feature is off);
414
+ the sentinel ``"__auto__"`` if given with no value (detect by name, see
415
+ :func:`_autodetect_grid_variables`); otherwise a comma-separated list of
416
+ indices and/or names, all of one kind, not mixed (matching
417
+ ``-v``/``--variables`` elsewhere in this project).
418
+ union_vars: The merge's full union variable list.
419
+
420
+ Returns:
421
+ 1-based indices into *union_vars* eligible for grid sharing, or ``None`` if
422
+ ``--merge-grid`` wasn't given at all. An empty set (rather than ``None``) means
423
+ the feature is on but nothing was found/specified to share, --merge-grid then
424
+ has no effect.
425
+
426
+ Raises:
427
+ ValueError: If an explicit index is out of range, an explicit name isn't in
428
+ *union_vars*, or indices and names are mixed in the same list.
429
+ """
430
+ if explicit is None:
431
+ return None
432
+ if explicit == "__auto__":
433
+ return set(_autodetect_grid_variables(union_vars))
434
+
435
+ tokens = _parse_index_or_name_list(explicit)
436
+ has_int = any(isinstance(t, int) for t in tokens)
437
+ has_name = any(isinstance(t, str) for t in tokens)
438
+ if has_int and has_name:
439
+ raise ValueError(
440
+ "--merge-grid indices and names cannot be mixed in the same list; "
441
+ f"use all indices or all names, got: {explicit!r}."
442
+ )
443
+
444
+ resolved: set[int] = set()
445
+ for token in tokens:
446
+ if isinstance(token, int):
447
+ if token < 1 or token > len(union_vars):
448
+ raise ValueError(
449
+ f"--merge-grid variable index {token} out of range "
450
+ f"[1, {len(union_vars)}]."
451
+ )
452
+ resolved.add(token)
453
+ else:
454
+ try:
455
+ resolved.add(union_vars.index(token) + 1)
456
+ except ValueError:
457
+ raise ValueError(
458
+ f"--merge-grid variable name {token!r} not found; available "
459
+ f"names: {', '.join(union_vars)}."
460
+ ) from None
461
+ return resolved
462
+
261
463
 
262
464
  def _expand_inputs(patterns: list[str]) -> list[Path]:
263
465
  """Expand a list of file paths / glob patterns to a sorted list of Paths.
@@ -338,6 +540,9 @@ def _write_zone(
338
540
  solution_time: float | None,
339
541
  strand_id: int | None,
340
542
  zone_index_map: dict[int, int],
543
+ source_path: Path,
544
+ grid_var_indices: set[int] | None,
545
+ grid_reference: int | None,
341
546
  ) -> None:
342
547
  """Write one zone to *writer* using the reconciled variable list.
343
548
 
@@ -348,12 +553,24 @@ def _write_zone(
348
553
  writer: Open writer instance.
349
554
  zone: Source zone reader.
350
555
  union_vars: Full union variable name list.
351
- local_index_map: Map from union index -> local 0-based var index
352
- (``None`` = not present in this file).
556
+ local_index_map: Map from union index -> local 0-based var index (``None`` =
557
+ not present in this file).
353
558
  solution_time: Override solution time, or ``None`` to keep original.
354
559
  strand_id: Override strand ID, or ``None`` to keep original.
355
560
  zone_index_map: Map 1-based source zone to 1-based output zone index for
356
561
  variable and connectivity sharing.
562
+ source_path: Input file this zone came from, recorded as zone-level aux
563
+ data (``SourceFile``, ``SourceFileName``) on every merged
564
+ zone.
565
+ grid_var_indices: 1-based union indices of the ``--merge-grid`` grid variables,
566
+ or ``None`` if the feature is off.
567
+ grid_reference: 1-based output zone index to share this zone's grid variables
568
+ (and, for FE zones, connectivity) from, if an earlier zone
569
+ with a matching (zone_type, num_nodes, num_elements) signature
570
+ was already written, else ``None`` (this zone's grid is
571
+ written fresh; the caller is responsible for registering its
572
+ signature afterward). Ignored entirely when *grid_var_indices*
573
+ is ``None``.
357
574
  """
358
575
  zt = zone.zone_type
359
576
 
@@ -362,7 +579,22 @@ def _write_zone(
362
579
  passive_vars: list[bool] = []
363
580
  var_sharing: list[int] = []
364
581
 
365
- for local_idx in local_index_map:
582
+ for union_i, local_idx in enumerate(local_index_map, start=1):
583
+ is_grid_var = grid_var_indices is not None and union_i in grid_var_indices
584
+
585
+ if is_grid_var and grid_reference is not None:
586
+ # An earlier zone with the same zone_type/num_nodes/num_elements already
587
+ # wrote this grid so share rather than duplicate
588
+ passive_vars.append(False)
589
+ var_sharing.append(grid_reference)
590
+ if local_idx is not None:
591
+ loc = zone.variables[local_idx].value_location
592
+ else:
593
+ loc = ValueLocation.NODAL
594
+ active_locs.append(loc)
595
+ active_data.append(np.array([], dtype=np.float32))
596
+ continue
597
+
366
598
  if local_idx is None:
367
599
  # Variable not in this file -- mark passive.
368
600
  passive_vars.append(True)
@@ -410,9 +642,13 @@ def _write_zone(
410
642
  if not is_p and sv == 0
411
643
  ]
412
644
 
413
- zone_aux: dict[str, str] | None = None
645
+ zone_aux: dict[str, str] = {}
414
646
  if len(zone.auxdata) > 0:
415
647
  zone_aux = dict(zone.auxdata.items())
648
+ # Always added, source provenance is cheap to record and easy to ignore in the
649
+ # Tecplot GUI if unwanted
650
+ zone_aux["SourceFile"] = str(source_path)
651
+ zone_aux["SourceFileName"] = source_path.name
416
652
 
417
653
  s_time = solution_time if solution_time is not None else zone.solution_time
418
654
  s_id = strand_id if strand_id is not None else zone.strand_id
@@ -430,8 +666,14 @@ def _write_zone(
430
666
  if isinstance(zone, TecplotOrderedZoneReader):
431
667
  writer.write_ordered_zone(data=writer_data, **common_kw)
432
668
  elif isinstance(zone, TecplotFEZoneReader):
433
- con_src = zone.shared_connectivity
434
- con_remapped = zone_index_map.get(con_src) if con_src is not None else None
669
+ if grid_var_indices is not None and grid_reference is not None:
670
+ # Share connectivity from the same reference zone as the grid variables, a
671
+ # matching (zone_type, num_nodes, num_elements) signature implies the same
672
+ # mesh, node map included.
673
+ con_remapped: int | None = grid_reference
674
+ else:
675
+ con_src = zone.shared_connectivity
676
+ con_remapped = zone_index_map.get(con_src) if con_src is not None else None
435
677
  fe_kw = common_kw.copy()
436
678
 
437
679
  # Face-neighbor connections
@@ -530,6 +772,17 @@ def main(argv: Sequence[str] | None = None) -> int:
530
772
 
531
773
  print(f"\nUnion variable list ({n_union}): {union_vars}")
532
774
 
775
+ try:
776
+ grid_var_indices = _resolve_grid_variable_indices(
777
+ args.merge_grid, union_vars
778
+ )
779
+ except ValueError as exc:
780
+ print(f"Error: {exc}", file=sys.stderr)
781
+ return 1
782
+ if grid_var_indices is not None:
783
+ grid_names = [union_vars[i - 1] for i in sorted(grid_var_indices)]
784
+ print(f"Grid sharing enabled for variables: {grid_names}")
785
+
533
786
  # Report any variables that will be passive in some files.
534
787
  for fi, (_reader, imap) in enumerate(zip(readers, index_maps, strict=False)):
535
788
  missing = [union_vars[ui] for ui, li in enumerate(imap) if li is None]
@@ -566,6 +819,8 @@ def main(argv: Sequence[str] | None = None) -> int:
566
819
  writer.add_auxvar_dict(auxvar)
567
820
 
568
821
  total_zones = 0
822
+ # Persists sharing registry across every input file for merge grid option
823
+ signature_registry: dict[tuple[ZoneType, int, int], int] = {}
569
824
  for fi, (reader, imap) in enumerate(zip(readers, index_maps, strict=False)):
570
825
  sol_time = times[fi] if times is not None else None
571
826
 
@@ -595,6 +850,14 @@ def main(argv: Sequence[str] | None = None) -> int:
595
850
  else:
596
851
  s_id = zone_num
597
852
 
853
+ grid_reference: int | None = None
854
+ signature: tuple[ZoneType, int, int] | None = None
855
+ if grid_var_indices is not None and isinstance(
856
+ zone, (TecplotOrderedZoneReader, TecplotFEZoneReader)
857
+ ):
858
+ signature = (zt, zone.num_nodes, zone.num_elements)
859
+ grid_reference = signature_registry.get(signature)
860
+
598
861
  _write_zone(
599
862
  writer=writer,
600
863
  zone=zone,
@@ -603,8 +866,15 @@ def main(argv: Sequence[str] | None = None) -> int:
603
866
  solution_time=sol_time,
604
867
  strand_id=s_id,
605
868
  zone_index_map=zone_index_map,
869
+ source_path=input_paths[fi],
870
+ grid_var_indices=grid_var_indices,
871
+ grid_reference=grid_reference,
606
872
  )
607
873
  zone_index_map[zone_num] = writer.current_zone
874
+ if signature is not None and grid_reference is None:
875
+ # First zone with this signature should write grid, every later
876
+ # zone with a matching signature shares from it
877
+ signature_registry[signature] = writer.current_zone
608
878
  total_zones += 1
609
879
 
610
880
  # Close all readers
@@ -0,0 +1,111 @@
1
+ function f = fecplot(dim, x, y, z, c, node_map, varargin)
2
+ % Filled patch contour for unstructured 2D/3D surface zones
3
+ %
4
+ % Call:
5
+ % h = feplot(dim, x, y, z, c, node_map, ...)
6
+ %
7
+ % Args:
8
+ % dim: 2 or 3
9
+ % x,y[,z]: nodal coordinate column vectors (Nx1)
10
+ % c: contour values; either nodal (Nx1) or cell-centered (Mcells x 1)
11
+ % node_map: M x K integer matrix of node indices per cell (K=3 or 4)
12
+ % varargin: name-value pairs forwarded to patch (e.g. 'EdgeColor','none')
13
+ %
14
+ % Returns:
15
+ % f: handle to the patch object
16
+ %
17
+ % Notes:
18
+ % - If c is nodal, uses 'FaceVertexCData' with 'FaceColor'='interp'
19
+ % - If c is cell-centered, uses 'CData' per-face with 'FaceColor'='flat'
20
+ % - For dim==3, z must be provided and plotting uses 3D patch (viewable with lighting)
21
+ % - node_map may contain NaNs for unequal-sided cells; those faces are handled
22
+
23
+ % Validate dim
24
+ if ~ismember(dim, [2,3])
25
+ error('dim must be 2 or 3');
26
+ end
27
+
28
+ % Ensure column vectors
29
+ x = x(:); y = y(:);
30
+ if dim == 3
31
+ if nargin < 4 || isempty(z)
32
+ error('z must be provided for dim==3');
33
+ end
34
+ z = z(:);
35
+ end
36
+
37
+ % Determine number of nodes and faces
38
+ nNodes = numel(x);
39
+ [nFaces, ~] = size(node_map);
40
+
41
+ % Validate node_map indices
42
+ if any(node_map(:) > nNodes) || any(node_map(:) < 0 & ~isnan(node_map(:)))
43
+ error('node_map contains invalid node indices.');
44
+ end
45
+
46
+ % Prepare vertices matrix
47
+ if dim == 2
48
+ verts = [x, y];
49
+ else
50
+ verts = [x, y, z];
51
+ end
52
+
53
+ % Determine if c is nodal or cell-centered
54
+ c = c(:);
55
+ isNodal = (numel(c) == nNodes);
56
+ isCell = (numel(c) == nFaces);
57
+ if ~(isNodal || isCell)
58
+ error('Length of c must match number of nodes (nodal) or number of faces (cell-centered).');
59
+ end
60
+
61
+ % Clean node_map: replace zeros -> NaN (if any), ensure double
62
+ node_map = double(node_map);
63
+ node_map(node_map==0) = NaN;
64
+
65
+ % If node_map contains NaNs for some cells, patch accepts NaN-separated faces.
66
+ % Create faces in appropriate format: patch accepts face vertex indices matrix.
67
+ faces = node_map;
68
+
69
+ % Build patch arguments
70
+ pArgs = varargin;
71
+
72
+ if isNodal
73
+ % Nodal data: use FaceVertexCData with interpolation
74
+ % Create patch with vertices and faces, supply FaceVertexCData
75
+ % Set FaceColor to 'interp' and EdgeColor as provided or 'none' by default
76
+ if ~any(strcmpi('FaceColor',pArgs))
77
+ pArgs = [{'FaceColor','interp'}, pArgs];
78
+ end
79
+ if ~any(strcmpi('EdgeColor',pArgs))
80
+ pArgs = [{'EdgeColor','none'}, pArgs];
81
+ end
82
+ % Create patch
83
+ f = patch('Vertices', verts, 'Faces', faces, 'FaceVertexCData', c, pArgs{:});
84
+
85
+ else
86
+ % Cell-centered: supply per-face CData and use flat coloring
87
+ if ~any(strcmpi('FaceColor',pArgs))
88
+ pArgs = [{'FaceColor','flat'}, pArgs];
89
+ end
90
+ if ~any(strcmpi('EdgeColor',pArgs))
91
+ pArgs = [{'EdgeColor','none'}, pArgs];
92
+ end
93
+ % patch expects CData as Mx1 or Mx3 color; provide as Mx1
94
+ faceCData = c;
95
+ % Create patch
96
+ f = patch('Vertices', verts, 'Faces', faces, 'CData', faceCData, pArgs{:});
97
+
98
+ end
99
+
100
+ % Set colormap and colorbar behavior consistent with contourf
101
+ if ~any(strcmpi('EdgeColor',pArgs))
102
+ set(f,'EdgeColor','none');
103
+ end
104
+ axis equal
105
+ if dim == 3
106
+ view(3)
107
+ camlight headlight
108
+ lighting gouraud
109
+ end
110
+ colorbar
111
+ end
@@ -170,6 +170,9 @@ _AXIS_SYNONYMS: dict[str, frozenset[str]] = {
170
170
  "coordx",
171
171
  "xcoord",
172
172
  "x_coord",
173
+ "xgrid",
174
+ "x-grid",
175
+ "x_grid",
173
176
  }),
174
177
  "y": frozenset({
175
178
  "y",
@@ -180,6 +183,9 @@ _AXIS_SYNONYMS: dict[str, frozenset[str]] = {
180
183
  "coordy",
181
184
  "ycoord",
182
185
  "y_coord",
186
+ "ygrid",
187
+ "y-grid",
188
+ "y_grid",
183
189
  }),
184
190
  "z": frozenset({
185
191
  "z",
@@ -190,6 +196,9 @@ _AXIS_SYNONYMS: dict[str, frozenset[str]] = {
190
196
  "coordz",
191
197
  "zcoord",
192
198
  "z_coord",
199
+ "zgrid",
200
+ "z-grid",
201
+ "z_grid",
193
202
  }),
194
203
  }
195
204
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tecio-python
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Python interface for reading and writing Tecplot data files
5
5
  Project-URL: Homepage, https://github.com/meersman/tecio
6
6
  Project-URL: Documentation, https://meersman.github.io/tecio/
@@ -1,9 +1,9 @@
1
- tecio/__init__.py,sha256=ygtKbxih989WAsj0yOhPX4m6nXgLUX8PtpBRT1hNL-k,2852
1
+ tecio/__init__.py,sha256=-aohTaSV10NJQTIkixETvD1r34zjOwS2vOI72xNXwIc,3211
2
2
  tecio/_constants.py,sha256=k6o3FSuE2mCTtJ431Bg4hzkgVW34-ENEY3U3pLz6CN0,7190
3
3
  tecio/_containers.py,sha256=PmVUwYfImIHB8uCHtA0AWQEcOHGBRkGlkMRrY8ONGZA,10501
4
- tecio/_dat_read.py,sha256=JNZe2KaaE8LyMeYUTKcvHth7DfLOrp2uskxi1rAQvLM,72979
4
+ tecio/_dat_read.py,sha256=Bm17K53DG6ObX7UQA56ghcJQ5UApIJ7Do7yIDIVslvw,81146
5
5
  tecio/_dat_write.py,sha256=bB95ziTg9lMG3DdgIg3LKLxtytRM2_qWFp2TRbgSrO4,39994
6
- tecio/_io.py,sha256=r1Ov0ky98aX-FY7V07mThprhQVTg-oMbju08OWHAyQQ,31525
6
+ tecio/_io.py,sha256=jC3GG5IgvoVXc7s5zoupTdDEqhwtRX840VGfS5Dq7WE,39759
7
7
  tecio/_meta.py,sha256=EqOqejk3wjtvB2KSzXZgn6nzDTvIuQqsYsm_agBpr1M,8028
8
8
  tecio/_plt_read.py,sha256=3SRWvqbHeIll_zB_TTUUUWibtEaoeoN0x0C0BYm3XY4,56523
9
9
  tecio/_plt_write.py,sha256=kq3waOr5TWInNHbT6YdW2HRw5mN5er_IhLLnPLPyPFU,31735
@@ -19,16 +19,17 @@ tecio/cli/tecaux.py,sha256=1ZCL5Ef_8cfR7TXm8tSxNTAgLZnnyDvIawwnVJhfNYg,36712
19
19
  tecio/cli/tecdump.py,sha256=pBWYjVMlQNzan5mgfFeLrvRQluRXAGX3JrM0XHSbXmE,12119
20
20
  tecio/cli/tecextract.py,sha256=cM70fcwbQE4ziXVFSkhC1ISOlQpXPz-VFI_votqVynM,19435
21
21
  tecio/cli/tecfix.py,sha256=c4DGYJ0O48dHW4HppZ2dLVW8HKKtTBsp1MksxYWgiP8,20313
22
- tecio/cli/tecmerge.py,sha256=3MiZIqkUpXSkZPHX0lhQdJbvZA6eP6P9B593fykjzyw,22336
22
+ tecio/cli/tecmerge.py,sha256=NQ1NMhjvus9Pt02AJO_ZjL7i7N35BoLwdJ-IpZn5EF0,33614
23
23
  tecio/cli/teconvert.py,sha256=x2Xmcyn9O7IB-ioE_f_TkEWLrJcMpMy03FLO3X58DTY,12865
24
24
  tecio/cli/tecscale.py,sha256=-uSdjfa82g7LIfcj-liMyXpsKNAAACDGP5fGa_pxiyE,14810
25
25
  tecio/cli/tecslice.py,sha256=n442ixabc7S74FZwLwo24GShvR1DavHR_Sh7Q07W3og,36246
26
26
  tecio/cli/tecstats.py,sha256=kLPz41JBsmgDRL_YrVRZSp_qoW2c-bmb5NI2eFD691k,20923
27
- tecio_python-0.3.2.data/data/share/tecio/plugins/paraview/TecplotTecioReader.py,sha256=FQC2KcSSRTvEOeYKLFVW_jkgz2Y-6fV9XikRkcO2GLc,44022
28
- tecio_python-0.3.2.dist-info/licenses/LICENSE,sha256=WYmcYJG1QFgu1hfo7qrEkZ3Jhcz8NUWe6XUraZvlIFs,10172
29
- tecio_python-0.3.2.dist-info/licenses/NOTICE,sha256=bDcDOguk8dYN4FpHJCHuCsDgR2VnP5XlK2C4BzVCtwg,340
30
- tecio_python-0.3.2.dist-info/METADATA,sha256=Rd4pM4RA6ivdVSpjj-7eSskc_n3Nt7IPTYYX4a8hUhE,5102
31
- tecio_python-0.3.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
- tecio_python-0.3.2.dist-info/entry_points.txt,sha256=TH2UnRF700KO0GjeRFwkBBtFKN1PyP2tgrc11OsVNAc,362
33
- tecio_python-0.3.2.dist-info/top_level.txt,sha256=ayCfhWQNUimgwyaTSyamJ3LbgjloZRWl2vlcZCjYWmQ,6
34
- tecio_python-0.3.2.dist-info/RECORD,,
27
+ tecio_python-0.3.3.data/data/share/plugins/matlab/fecplot.m,sha256=YYPjn7FmIVcE5K4a73WkO_ZV_sNJ4l0426-OYHFqkos,3206
28
+ tecio_python-0.3.3.data/data/share/plugins/paraview/TecplotTecioReader.py,sha256=JXttRwxlHXbKuqh5V-GRkiEh7ACm1xjvgr2KY9xIUXA,44181
29
+ tecio_python-0.3.3.dist-info/licenses/LICENSE,sha256=WYmcYJG1QFgu1hfo7qrEkZ3Jhcz8NUWe6XUraZvlIFs,10172
30
+ tecio_python-0.3.3.dist-info/licenses/NOTICE,sha256=bDcDOguk8dYN4FpHJCHuCsDgR2VnP5XlK2C4BzVCtwg,340
31
+ tecio_python-0.3.3.dist-info/METADATA,sha256=xwyDjPGDBbW4ZQ7vD-xdc_JUVk6ZeKrfKak46FVeUIw,5102
32
+ tecio_python-0.3.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
33
+ tecio_python-0.3.3.dist-info/entry_points.txt,sha256=TH2UnRF700KO0GjeRFwkBBtFKN1PyP2tgrc11OsVNAc,362
34
+ tecio_python-0.3.3.dist-info/top_level.txt,sha256=ayCfhWQNUimgwyaTSyamJ3LbgjloZRWl2vlcZCjYWmQ,6
35
+ tecio_python-0.3.3.dist-info/RECORD,,