tecio-python 0.3.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. tecio/__init__.py +29 -3
  2. tecio/_dat_read.py +296 -81
  3. tecio/_dat_write.py +206 -146
  4. tecio/_io.py +338 -73
  5. tecio/_meta.py +6 -6
  6. tecio/_plt_legacy.py +400 -0
  7. tecio/_plt_read.py +180 -41
  8. tecio/_plt_write.py +195 -125
  9. tecio/_reader.py +82 -37
  10. tecio/_szl_read.py +68 -61
  11. tecio/_szl_write.py +249 -188
  12. tecio/_writer.py +380 -134
  13. tecio/cli/tec2mat.py +21 -11
  14. tecio/cli/tecaux.py +77 -93
  15. tecio/cli/tecdump.py +1 -1
  16. tecio/cli/tecextract.py +68 -86
  17. tecio/cli/tecfix.py +45 -64
  18. tecio/cli/tecmerge.py +343 -66
  19. tecio/cli/teconvert.py +22 -37
  20. tecio/cli/tecscale.py +185 -109
  21. tecio/cli/tecslice.py +75 -100
  22. tecio/cli/tecstats.py +41 -41
  23. tecio_python-0.4.0.data/data/share/plugins/matlab/contourfe.m +111 -0
  24. tecio_python-0.4.0.data/data/share/plugins/matlab/tecmat2struct.m +137 -0
  25. {tecio_python-0.3.2.data/data/share/tecio → tecio_python-0.4.0.data/data/share}/plugins/paraview/TecplotTecioReader.py +9 -0
  26. tecio_python-0.4.0.data/data/share/plugins/paraview/TecplotTecioWriter.py +651 -0
  27. {tecio_python-0.3.2.dist-info → tecio_python-0.4.0.dist-info}/METADATA +1 -1
  28. tecio_python-0.4.0.dist-info/RECORD +38 -0
  29. {tecio_python-0.3.2.dist-info → tecio_python-0.4.0.dist-info}/WHEEL +1 -1
  30. tecio_python-0.3.2.dist-info/RECORD +0 -34
  31. {tecio_python-0.3.2.dist-info → tecio_python-0.4.0.dist-info}/entry_points.txt +0 -0
  32. {tecio_python-0.3.2.dist-info → tecio_python-0.4.0.dist-info}/licenses/LICENSE +0 -0
  33. {tecio_python-0.3.2.dist-info → tecio_python-0.4.0.dist-info}/licenses/NOTICE +0 -0
  34. {tecio_python-0.3.2.dist-info → tecio_python-0.4.0.dist-info}/top_level.txt +0 -0
tecio/__init__.py CHANGED
@@ -4,12 +4,14 @@ Wraps the TecIO C library for ``.szplt``, ``.plt``, and ``.dat`` formats.
4
4
  Requires Python 3.10+, NumPy, and a Tecplot 360 installation.
5
5
  """
6
6
 
7
+ # ruff: noqa: A004
8
+
7
9
  from importlib import metadata
8
10
 
9
11
  try:
10
12
  __version__ = metadata.version("tecio")
11
13
  except metadata.PackageNotFoundError:
12
- __version__ = "0.3.2"
14
+ __version__ = "0.4.0"
13
15
 
14
16
  from . import cli, libtecio
15
17
  from ._constants import (
@@ -28,7 +30,20 @@ from ._constants import (
28
30
  from ._containers import VariableList, ZoneList
29
31
  from ._dat_read import TecplotDatReader
30
32
  from ._dat_write import TecplotDatWriter
31
- from ._io import AppendReadWrite, AppendWrite, open
33
+ from ._io import (
34
+ AppendReadWrite,
35
+ AppendWrite,
36
+ FileSummary,
37
+ ZoneSummary,
38
+ get_file_type,
39
+ get_num_variables,
40
+ get_num_zones,
41
+ get_title,
42
+ get_variable_list,
43
+ get_zone_list,
44
+ open,
45
+ peek,
46
+ )
32
47
  from ._plt_read import TecplotPltReader
33
48
  from ._plt_write import TecplotPltWriter
34
49
  from ._reader import (
@@ -41,7 +56,7 @@ from ._reader import (
41
56
  )
42
57
  from ._szl_read import TecplotSzlReader
43
58
  from ._szl_write import TecplotSzlWriter
44
- from ._writer import TecplotWriter
59
+ from ._writer import EmptyZoneWarning, TecplotWriter
45
60
 
46
61
  # Ensure these display as their canonical public name in docs and help(),
47
62
  # rather than the private module they're actually defined in.
@@ -64,6 +79,7 @@ for _cls in (
64
79
  TecplotSzlWriter,
65
80
  TecplotPltWriter,
66
81
  TecplotDatWriter,
82
+ EmptyZoneWarning,
67
83
  Boolean,
68
84
  DataPacking,
69
85
  DataType,
@@ -82,6 +98,15 @@ del _cls
82
98
  __all__ = [
83
99
  "libtecio",
84
100
  "open",
101
+ "peek",
102
+ "get_variable_list",
103
+ "get_zone_list",
104
+ "get_num_zones",
105
+ "get_num_variables",
106
+ "get_title",
107
+ "get_file_type",
108
+ "FileSummary",
109
+ "ZoneSummary",
85
110
  "cli",
86
111
  "AppendWrite",
87
112
  "AppendReadWrite",
@@ -100,6 +125,7 @@ __all__ = [
100
125
  "TecplotSzlWriter",
101
126
  "TecplotPltWriter",
102
127
  "TecplotDatWriter",
128
+ "EmptyZoneWarning",
103
129
  "Boolean",
104
130
  "DataPacking",
105
131
  "DataType",
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
@@ -36,6 +37,7 @@ from ._reader import (
36
37
  TecplotReader,
37
38
  TecplotVariableReader,
38
39
  TecplotZoneReader,
40
+ convert_face_connections_to_zero_based,
39
41
  )
40
42
 
41
43
  # --------------------------------------------------------------------------------------
@@ -299,6 +301,93 @@ def _next_token_is_value(text: str, i: int) -> bool:
299
301
  return not (j < n and text[j] == "=")
300
302
 
301
303
 
304
+ def _determine_zone_type(kv: dict[str, str]) -> ZoneType:
305
+ """Determine a zone's type from its header key-value pairs.
306
+
307
+ Shared by the full parser and the metadata-only scanner so both agree on the same
308
+ modern/legacy keyword rules.
309
+
310
+ Two header dialects are supported:
311
+ * Modern: ``ZONETYPE=FEQuadrilateral, DATAPACKING=POINT``
312
+ * Legacy: ``F=FEPOINT, ET=QUADRILATERAL``
313
+
314
+ The FE-vs-ordered distinction comes from ``ZONETYPE`` (modern) or ``F``
315
+ (legacy). ``ET`` (element type) only ever appears on finite-element zones, so it
316
+ merely names the element *shape* once a zone is already known to be FE. Modern
317
+ keywords win when present.
318
+
319
+ Args:
320
+ kv: Zone header key-value pairs (leading ``ZONE`` keyword already stripped), as
321
+ returned by :func:`_kv_split`.
322
+
323
+ Returns:
324
+ The zone's type. Unlike the full parser, does not reject FEPOLYGON/FEPOLYHEDRON,
325
+ those just aren't readable for data, the header itself is perfectly legible.
326
+
327
+ Raises:
328
+ ValueError: If a legacy FE header (``F=FEPOINT``/``FEBLOCK``) is
329
+ missing the required ``ET`` keyword.
330
+
331
+ Example:
332
+ >>> _determine_zone_type({"T": '"z"', "I": "5"})
333
+ <ZoneType.ORDERED: ...>
334
+ """
335
+ legacy_is_fe: bool | None = None
336
+ if "F" in kv:
337
+ legacy_is_fe, _ = _parse_legacy_format(kv["F"])
338
+
339
+ if "ZONETYPE" in kv:
340
+ zt_raw = kv["ZONETYPE"].rstrip(",").strip().lower()
341
+ return _STR_TO_ZONETYPE.get(zt_raw, ZoneType.ORDERED)
342
+ elif legacy_is_fe is False:
343
+ return ZoneType.ORDERED
344
+ elif legacy_is_fe or "ET" in kv:
345
+ if "ET" not in kv:
346
+ raise ValueError(
347
+ "Legacy FE zone header specifies F=FEPOINT/FEBLOCK but is "
348
+ "missing the required ET (element type) keyword."
349
+ )
350
+ return _parse_legacy_element_type(kv["ET"])
351
+ else:
352
+ return ZoneType.ORDERED
353
+
354
+
355
+ def _collect_zone_header_lines(tokens: _LineBuffer) -> list[str]:
356
+ """Collect a ZONE block's header lines, stopping before data begins.
357
+
358
+ Shared by the full parser and the metadata-only scanner (:func:`peek_dat_metadata`)
359
+ so both agree on exactly where a zone's header ends and its data starts.
360
+
361
+ Args:
362
+ tokens: Positioned at the ``ZONE`` line itself (not yet consumed).
363
+
364
+ Returns:
365
+ Raw header lines, including the leading ``ZONE`` keyword line.
366
+
367
+ Example:
368
+ >>> lines = _collect_zone_header_lines(tokens)
369
+ """
370
+ header_lines: list[str] = [tokens.next_stripped()] # ZONE T=...
371
+ while tokens.has_more():
372
+ nxt = tokens.peek_stripped()
373
+ if not nxt:
374
+ tokens.next_stripped()
375
+ continue
376
+ nxt_upper = nxt.lstrip().upper()
377
+ # Stop at a new zone, top-level keyword, or the first data line.
378
+ if nxt_upper.split("=")[0].split()[0] == "ZONE":
379
+ break
380
+ if nxt_upper.startswith(("DATASETAUXDATA", "VARAUXDATA")):
381
+ break
382
+ first_ch = nxt.lstrip()[0] if nxt.lstrip() else ""
383
+ # A data line begins with a numeric token (covers leading-dot values such as
384
+ # ``.5`` and signed values such as ``-1.2e3``).
385
+ if first_ch in "0123456789+-.":
386
+ break
387
+ header_lines.append(tokens.next_stripped())
388
+ return header_lines
389
+
390
+
302
391
  def _kv_split(text: str) -> dict[str, str]:
303
392
  """Parse a loose ``KEY=VALUE`` string into an upper-cased-key dict.
304
393
 
@@ -608,9 +697,9 @@ def _parse_auxdata_line(line: str) -> tuple[str, str]:
608
697
  def _apply_varauxdata(line: str, var_auxdata_list: list[dict[str, str]]) -> None:
609
698
  """Parse ``VARAUXDATA 1-based-idx name="value"`` and store in list.
610
699
 
611
- *var_auxdata_list* holds plain, mutable dicts, one per variable (index 0 is an
612
- unused placeholder), accumulated during parsing. Wrapped into an immutable
613
- TecplotDatAuxDataReader lazily, only once parsing is complete.
700
+ *var_auxdata_list* holds plain, mutable dicts, one per variable, 0-based,
701
+ accumulated during parsing. Wrapped into an immutable TecplotDatAuxDataReader
702
+ lazily, only once parsing is complete.
614
703
 
615
704
  Example:
616
705
  >>> _apply_varauxdata(line, var_auxdata_list)
@@ -618,14 +707,14 @@ def _apply_varauxdata(line: str, var_auxdata_list: list[dict[str, str]]) -> None
618
707
  m = re.match(r"(?i)VARAUXDATA\s+(\d+)\s+", line)
619
708
  if not m:
620
709
  return
621
- var_idx = int(m.group(1)) # 1-based
710
+ var_idx = int(m.group(1)) - 1 # 1-based in text, converted to 0-based
622
711
  rest = line[m.end() :]
623
712
  eq = rest.find("=")
624
713
  if eq < 0:
625
714
  return
626
715
  name = rest[:eq].strip()
627
716
  value = _unquote(rest[eq + 1 :].strip())
628
- if 1 <= var_idx < len(var_auxdata_list):
717
+ if 0 <= var_idx < len(var_auxdata_list):
629
718
  var_auxdata_list[var_idx][name] = value
630
719
 
631
720
 
@@ -839,7 +928,7 @@ class TecplotDatVariableReader(TecplotVariableReader):
839
928
 
840
929
  @property
841
930
  def shared_zone(self) -> int | None:
842
- """1-based source zone index if shared, else None."""
931
+ """0-based source zone index if shared, else None."""
843
932
  return self._shared_zone
844
933
 
845
934
  @property
@@ -897,9 +986,9 @@ class TecplotDatOrderedZoneReader(TecplotOrderedZoneReader):
897
986
  self,
898
987
  zone_index: int,
899
988
  title: str,
900
- I: int, # noqa: E741 - matches Tecplot's own IJK convention
901
- J: int,
902
- K: int,
989
+ I: int, # noqa: E741 N803
990
+ J: int, # noqa: N803
991
+ K: int, # noqa: N803
903
992
  solution_time: float,
904
993
  strand_id: int,
905
994
  variables: list[TecplotVariableReader],
@@ -1022,6 +1111,162 @@ class TecplotDatFEZoneReader(TecplotFEZoneReader):
1022
1111
  return TecplotDatAuxDataReader(self._aux_raw)
1023
1112
 
1024
1113
 
1114
+ class _DatZoneInfo(NamedTuple):
1115
+ """Per-zone metadata from a cheap header-only scan."""
1116
+
1117
+ title: str
1118
+ zone_type: ZoneType
1119
+ num_nodes: int | None
1120
+ num_elements: int | None
1121
+
1122
+
1123
+ class _DatMetadata(NamedTuple):
1124
+ """Result of :func:`_peek_dat_metadata`."""
1125
+
1126
+ title: str
1127
+ file_type: FileType
1128
+ variable_names: list[str]
1129
+ zones: list[_DatZoneInfo]
1130
+
1131
+
1132
+ _ZONE_LINE_RE = re.compile(r"^[ \t]*ZONE\b", re.IGNORECASE | re.MULTILINE)
1133
+ _ZONE_DATA_START_RE = re.compile(r"^[ \t]*[0-9+\-.]", re.MULTILINE)
1134
+
1135
+
1136
+ def _peek_dat_metadata(path: str | os.PathLike) -> _DatMetadata:
1137
+ """Scan a DAT file's title, variable names, and per-zone metadata only.
1138
+
1139
+ Unlike opening the file normally, never converts a single data value to a number. A
1140
+ single bulk regex pass across the whole file finds every zone header's starting
1141
+ line; each zone's metadata is then extracted from just the handful of lines between
1142
+ that start and the next one (or EOF), reusing the same header-parsing logic the real
1143
+ parser uses, so a zone header can still span multiple lines correctly. The data and
1144
+ connectivity blocks in between are never read at all. A line-by-line Python scan
1145
+ over the whole file (rather than this bulk approach) was tried first and measured
1146
+ slower than a full parse for large files, string-heavy per-line loops in Python
1147
+ don't beat NumPy's bulk numeric parsing, even when doing less work; a single C-level
1148
+ regex pass over the whole file does.
1149
+
1150
+ A zone's ``num_nodes``/``num_elements`` are ``None`` only for the one case they
1151
+ genuinely can't be determined without reading data: an ordered zone whose header
1152
+ omits I/J/K entirely, relying on the reader to infer the point count from the data
1153
+ itself (see :meth:`TecplotDatReader._infer_ordered_point_count`). Every other case,
1154
+ including FE zones, always states its node/element counts directly in the header
1155
+ text.
1156
+
1157
+ Args:
1158
+ path: Path to a ``.dat``/``.tec`` file.
1159
+
1160
+ Returns:
1161
+ Title, file type, variable names, and per-zone metadata (in file order).
1162
+
1163
+ Raises:
1164
+ FileNotFoundError: If *path* does not exist.
1165
+
1166
+ Example:
1167
+ >>> meta = _peek_dat_metadata("flow.dat")
1168
+ >>> [z.title for z in meta.zones]
1169
+ ['FluidVolume', 'WingSurface']
1170
+ """
1171
+ with open(path, encoding="utf-8", errors="replace") as fh:
1172
+ text = fh.read()
1173
+
1174
+ zone_starts = [m.start() for m in _ZONE_LINE_RE.finditer(text)]
1175
+
1176
+ # File header: everything before the first zone (or the whole file, if it somehow
1177
+ # has none). Mirrors TecplotDatReader._parse_file_header, kept separate rather than
1178
+ # shared with it since that method also populates dataset aux data, which this
1179
+ # function deliberately doesn't scan for. Just a handful of lines regardless of file
1180
+ # size, so no bulk-regex treatment needed here.
1181
+ header_end = zone_starts[0] if zone_starts else len(text)
1182
+ tokens = _LineBuffer(text[:header_end].splitlines(keepends=True))
1183
+
1184
+ title = ""
1185
+ file_type = FileType.FULL
1186
+ variable_names: list[str] = []
1187
+ while tokens.has_more():
1188
+ line = tokens.peek_stripped()
1189
+ if not line:
1190
+ tokens.next_stripped()
1191
+ continue
1192
+ upper_key = (
1193
+ line.split("=")[0].strip().upper() if "=" in line else line.strip().upper()
1194
+ )
1195
+ tokens.next_stripped()
1196
+
1197
+ if upper_key == "TITLE":
1198
+ rhs = line.split("=", 1)[1].strip() if "=" in line else ""
1199
+ title = _unquote(rhs)
1200
+ elif upper_key == "FILETYPE":
1201
+ rhs = line.split("=", 1)[1].strip() if "=" in line else ""
1202
+ file_type = _STR_TO_FILETYPE.get(rhs.strip().lower(), FileType.FULL)
1203
+ elif upper_key == "VARIABLES":
1204
+ rhs = line.split("=", 1)[1].strip() if "=" in line else ""
1205
+ names = _extract_quoted_strings(rhs)
1206
+ while tokens.has_more():
1207
+ nxt = tokens.peek_stripped()
1208
+ if not nxt:
1209
+ tokens.next_stripped()
1210
+ continue
1211
+ if nxt.lstrip().startswith('"'):
1212
+ names.extend(_extract_quoted_strings(tokens.next_stripped()))
1213
+ elif "=" not in nxt.split("#")[0]:
1214
+ tokens.next_stripped()
1215
+ else:
1216
+ break
1217
+ variable_names = names
1218
+
1219
+ # Zones: each zone's header lives in the small window between its start and the next
1220
+ # zone's start (or EOF); _collect_zone_header_lines only ever looks at the first
1221
+ # handful of lines in that window before hitting what looks like data, so this stays
1222
+ # cheap regardless of how much data actually follows.
1223
+ zones: list[_DatZoneInfo] = []
1224
+ for i, start in enumerate(zone_starts):
1225
+ end = zone_starts[i + 1] if i + 1 < len(zone_starts) else len(text)
1226
+ # Bounded search directly on the original string
1227
+ data_match = _ZONE_DATA_START_RE.search(text, start, end)
1228
+ header_end = data_match.start() if data_match else end
1229
+ window_tokens = _LineBuffer(text[start:header_end].splitlines(keepends=True))
1230
+ header_lines = _collect_zone_header_lines(window_tokens)
1231
+ header_text = " ".join(header_lines)
1232
+ m_zone = re.match(r"(?i)^ZONE\s*", header_text)
1233
+ if m_zone:
1234
+ header_text = header_text[m_zone.end() :]
1235
+ kv = _kv_split(header_text)
1236
+ zone_title = _unquote(kv.get("T", ""))
1237
+
1238
+ try:
1239
+ zone_type = _determine_zone_type(kv)
1240
+ except ValueError:
1241
+ # A legacy header F=FEPOINT with no ET reports as unknown
1242
+ zones.append(_DatZoneInfo(zone_title, ZoneType.ORDERED, None, None))
1243
+ continue
1244
+
1245
+ num_nodes: int | None
1246
+ num_elements: int | None
1247
+ if zone_type == ZoneType.ORDERED:
1248
+ if "I" not in kv and "J" not in kv and "K" not in kv:
1249
+ # Point count only knowable by reading data -> report unknown
1250
+ num_nodes = None
1251
+ num_elements = None
1252
+ else:
1253
+ zi = int(kv.get("I", "1") or "1")
1254
+ zj = int(kv.get("J", "1") or "1")
1255
+ zk = int(kv.get("K", "1") or "1")
1256
+ num_nodes = zi * zj * zk
1257
+ num_elements = max(zi - 1, 1) * max(zj - 1, 1) * max(zk - 1, 1)
1258
+ else:
1259
+ # FE zones (including FEPOLYGON/FEPOLYHEDRON, which the full parser can't
1260
+ # read data for, but whose header is just as legible as any other zone's)
1261
+ # always state counts directly.
1262
+ num_nodes = int(kv.get("NODES", kv.get("N", "0")) or "0")
1263
+ num_elements = int(kv.get("ELEMENTS", kv.get("E", "0")) or "0")
1264
+
1265
+ zones.append(_DatZoneInfo(zone_title, zone_type, num_nodes, num_elements))
1266
+
1267
+ return _DatMetadata(title, file_type, variable_names, zones)
1268
+
1269
+
1025
1270
  class TecplotDatReader(TecplotReader):
1026
1271
  """Reader for Tecplot ASCII DAT files.
1027
1272
 
@@ -1062,9 +1307,9 @@ class TecplotDatReader(TecplotReader):
1062
1307
  # TecplotDatAuxDataReader lazily, only once fully parsed.
1063
1308
  self._dataset_auxdata_raw: dict[str, str] = {}
1064
1309
  self._dataset_auxdata: TecplotAuxDataReader | None = None
1065
- # Index 0 is an unused placeholder so that 1-based indexing works directly;
1066
- # populated once num_vars is known, see _parse().
1067
- self._var_auxdata_raw: list[dict[str, str]] = [{}]
1310
+ # 0-based, one dict per variable; populated once num_vars is known, see
1311
+ # _parse().
1312
+ self._var_auxdata_raw: list[dict[str, str]] = []
1068
1313
  # Raw VARAUXDATA lines seen before the first zone, buffered by
1069
1314
  # _parse_file_header() and applied once _var_auxdata_raw is allocated with the
1070
1315
  # correct length (num_vars isn't known until the header finishes parsing).
@@ -1133,7 +1378,7 @@ class TecplotDatReader(TecplotReader):
1133
1378
  self._parse_file_header(tokens)
1134
1379
 
1135
1380
  # Build per-variable aux data slots now that num_vars is known.
1136
- self._var_auxdata_raw = [{}] + [{} for _ in range(self.num_vars)]
1381
+ self._var_auxdata_raw = [{} for _ in range(self.num_vars)]
1137
1382
 
1138
1383
  # Now that _var_auxdata exists, apply any VARAUXDATA lines that appeared before
1139
1384
  # the first zone
@@ -1143,8 +1388,10 @@ class TecplotDatReader(TecplotReader):
1143
1388
 
1144
1389
  while tokens.has_more():
1145
1390
  line = tokens.peek_stripped()
1391
+ if not line:
1392
+ tokens.next_stripped()
1393
+ continue
1146
1394
  upper = line.upper()
1147
- # if upper.startswith("ZONE"):
1148
1395
  if upper.lstrip().split("=")[0].split()[0] == "ZONE":
1149
1396
  self._parse_zone(tokens)
1150
1397
  elif upper.startswith("DATASETAUXDATA"):
@@ -1232,7 +1479,9 @@ class TecplotDatReader(TecplotReader):
1232
1479
  def _infer_ordered_point_count(tokens: _LineBuffer, active_count: int) -> int:
1233
1480
  """Infer an ordered zone's point count when I/J/K are all omitted.
1234
1481
 
1235
- Peeks ahead counting numeric tokens up to the next zone/keyword boundary or end
1482
+ Some legacy exporters write a bare ``ZONE F=POINT`` header with no dimensions at
1483
+ all, relying on the reader to count data rows itself. Peeks ahead (never
1484
+ consuming) counting numeric tokens up to the next zone/keyword boundary or end
1236
1485
  of file, using the same boundary rule :meth:`_parse_zone` already uses to know
1237
1486
  where a header ends and data begins, then restores the original position so the
1238
1487
  real read (later, driven by the now-known count) starts from the same place.
@@ -1283,25 +1532,7 @@ class TecplotDatReader(TecplotReader):
1283
1532
  """
1284
1533
  # -- Collect header lines ------------------------------------------------------
1285
1534
 
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())
1535
+ header_lines = _collect_zone_header_lines(tokens)
1305
1536
 
1306
1537
  header_text = " ".join(header_lines)
1307
1538
 
@@ -1320,38 +1551,13 @@ class TecplotDatReader(TecplotReader):
1320
1551
 
1321
1552
  # -- Determine zone type and data packing --------------------------------------
1322
1553
  #
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
1554
+ # See _determine_zone_type for the modern/legacy keyword rules this shares with
1555
+ # the metadata-only scanner (peek_dat_metadata).
1333
1556
  legacy_packing: DataPacking | None = None
1334
1557
  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
1558
+ _, legacy_packing = _parse_legacy_format(kv["F"])
1559
+
1560
+ zone_type = _determine_zone_type(kv)
1355
1561
 
1356
1562
  if zone_type in _FE_POLY:
1357
1563
  raise ValueError(
@@ -1372,15 +1578,15 @@ class TecplotDatReader(TecplotReader):
1372
1578
 
1373
1579
  if zone_type == ZoneType.ORDERED:
1374
1580
  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
1581
+ # Infer point count
1376
1582
  active_count = self.num_vars - len(passive_set) - len(share_map)
1377
1583
  num_nodes = self._infer_ordered_point_count(tokens, active_count)
1378
1584
  I = num_nodes # noqa E741
1379
- J = K = 1
1585
+ J = K = 1 # noqa: N806
1380
1586
  else:
1381
- I = int(kv.get("I", "1") or "1") # noqa E741
1382
- J = int(kv.get("J", "1") or "1")
1383
- K = int(kv.get("K", "1") or "1")
1587
+ I = int(kv.get("I", "1") or "1") # noqa: E741 N806
1588
+ J = int(kv.get("J", "1") or "1") # noqa: N806
1589
+ K = int(kv.get("K", "1") or "1") # noqa: N806
1384
1590
  num_nodes = I * J * K
1385
1591
  num_cells = max(I - 1, 1) * max(J - 1, 1) * max(K - 1, 1)
1386
1592
  else:
@@ -1491,12 +1697,15 @@ class TecplotDatReader(TecplotReader):
1491
1697
  else:
1492
1698
  nodes_per_cell = _NODES_PER_ELEM[zone_type]
1493
1699
  flat = self._read_int_block(tokens, num_cells * nodes_per_cell)
1494
- node_map = flat.reshape(num_cells, nodes_per_cell)
1700
+ # Node numbers are 1-based in ASCII; convert to 0-based indices
1701
+ # that point directly into the corresponding coordinate array.
1702
+ node_map = flat.reshape(num_cells, nodes_per_cell) - 1
1495
1703
 
1496
1704
  # -- Read face-neighbor connections (FE zones only, if declared) ---------------
1497
1705
  #
1498
- # Immediately follows connectivity in the file. Values are already
1499
- # 1-based in ASCII (unlike PLT's binary layout, no +1 needed here).
1706
+ # Immediately follows connectivity in the file. Values are 1-based in ASCII;
1707
+ # cell references, remote-zone references, and the face ordinal are all
1708
+ # converted to 0-based below.
1500
1709
 
1501
1710
  face_connections: npt.NDArray[np.int64] | None = None
1502
1711
  one_to_one = (
@@ -1510,6 +1719,9 @@ class TecplotDatReader(TecplotReader):
1510
1719
  face_connections = self._read_int_block(
1511
1720
  tokens, declared_face_connections * values_per
1512
1721
  )
1722
+ face_connections = convert_face_connections_to_zero_based(
1723
+ face_connections, face_neighbor_mode
1724
+ )
1513
1725
  elif face_neighbor_mode is not None:
1514
1726
  # "Many" modes: ragged, each record's own 4th value (nz) gives the
1515
1727
  # count of additional neighbor references that follow it.
@@ -1525,6 +1737,9 @@ class TecplotDatReader(TecplotReader):
1525
1737
  face_connections = (
1526
1738
  np.concatenate(pieces) if pieces else np.empty(0, dtype=np.int64)
1527
1739
  )
1740
+ face_connections = convert_face_connections_to_zero_based(
1741
+ face_connections, face_neighbor_mode
1742
+ )
1528
1743
 
1529
1744
  # -- Build ReadVariable and ReadZone objects -----------------------------------
1530
1745
 
@@ -1553,13 +1768,13 @@ class TecplotDatReader(TecplotReader):
1553
1768
  self._zones[src_zone_1based - 1].variables[var_idx].values
1554
1769
  )
1555
1770
 
1556
- # This zone's own 1-based index
1557
- zone_index = len(self._zones) + 1
1771
+ # This zone's own 0-based index
1772
+ zone_index = len(self._zones)
1558
1773
 
1559
1774
  read_vars: list[TecplotVariableReader] = [
1560
1775
  TecplotDatVariableReader(
1561
1776
  zone_index=zone_index,
1562
- var_index=idx + 1,
1777
+ var_index=idx,
1563
1778
  name=name,
1564
1779
  data=_shaped(
1565
1780
  var_arrays[idx],
@@ -1567,7 +1782,7 @@ class TecplotDatReader(TecplotReader):
1567
1782
  ),
1568
1783
  value_location=var_locs.get(idx, ValueLocation.NODAL),
1569
1784
  is_passive=(idx in passive_set),
1570
- shared_zone=share_map.get(idx, None),
1785
+ shared_zone=(share_map[idx] - 1 if idx in share_map else None),
1571
1786
  )
1572
1787
  for idx, name in enumerate(self._variable_names)
1573
1788
  ]
@@ -1586,7 +1801,7 @@ class TecplotDatReader(TecplotReader):
1586
1801
  auxdata=zone_aux,
1587
1802
  node_map=node_map,
1588
1803
  datapacking=packing,
1589
- shared_connectivity=con_share_zone if con_share_zone else None,
1804
+ shared_connectivity=(con_share_zone - 1) if con_share_zone else None,
1590
1805
  face_neighbor_mode=face_neighbor_mode,
1591
1806
  num_face_connections=declared_face_connections or None,
1592
1807
  face_connections=face_connections,
@@ -1599,9 +1814,9 @@ class TecplotDatReader(TecplotReader):
1599
1814
  zone_index: int,
1600
1815
  title: str,
1601
1816
  zone_type: ZoneType,
1602
- I: int, # noqa: E741 - matches Tecplot's own IJK convention
1603
- J: int,
1604
- K: int,
1817
+ I: int, # noqa: E741 N803
1818
+ J: int, # noqa: N803
1819
+ K: int, # noqa: N803
1605
1820
  solution_time: float,
1606
1821
  strand_id: int,
1607
1822
  variables: list[TecplotVariableReader],