tecio-python 0.3.2__tar.gz → 0.3.3__tar.gz

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 (45) hide show
  1. {tecio_python-0.3.2/tecio_python.egg-info → tecio_python-0.3.3}/PKG-INFO +1 -1
  2. tecio_python-0.3.3/plugins/matlab/fecplot.m +111 -0
  3. {tecio_python-0.3.2 → tecio_python-0.3.3}/plugins/paraview/TecplotTecioReader.py +9 -0
  4. {tecio_python-0.3.2 → tecio_python-0.3.3}/pyproject.toml +3 -2
  5. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/__init__.py +24 -2
  6. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_dat_read.py +258 -53
  7. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_io.py +251 -2
  8. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecmerge.py +278 -8
  9. {tecio_python-0.3.2 → tecio_python-0.3.3/tecio_python.egg-info}/PKG-INFO +1 -1
  10. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio_python.egg-info/SOURCES.txt +1 -0
  11. {tecio_python-0.3.2 → tecio_python-0.3.3}/tests/test_io.py +178 -0
  12. {tecio_python-0.3.2 → tecio_python-0.3.3}/LICENSE +0 -0
  13. {tecio_python-0.3.2 → tecio_python-0.3.3}/NOTICE +0 -0
  14. {tecio_python-0.3.2 → tecio_python-0.3.3}/README.md +0 -0
  15. {tecio_python-0.3.2 → tecio_python-0.3.3}/setup.cfg +0 -0
  16. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_constants.py +0 -0
  17. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_containers.py +0 -0
  18. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_dat_write.py +0 -0
  19. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_meta.py +0 -0
  20. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_plt_read.py +0 -0
  21. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_plt_write.py +0 -0
  22. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_reader.py +0 -0
  23. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_szl_read.py +0 -0
  24. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_szl_write.py +0 -0
  25. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_utils.py +0 -0
  26. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/_writer.py +0 -0
  27. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/__init__.py +0 -0
  28. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tec2mat.py +0 -0
  29. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecaux.py +0 -0
  30. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecdump.py +0 -0
  31. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecextract.py +0 -0
  32. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecfix.py +0 -0
  33. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/teconvert.py +0 -0
  34. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecscale.py +0 -0
  35. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecslice.py +0 -0
  36. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/cli/tecstats.py +0 -0
  37. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio/libtecio.py +0 -0
  38. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio_python.egg-info/dependency_links.txt +0 -0
  39. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio_python.egg-info/entry_points.txt +0 -0
  40. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio_python.egg-info/requires.txt +0 -0
  41. {tecio_python-0.3.2 → tecio_python-0.3.3}/tecio_python.egg-info/top_level.txt +0 -0
  42. {tecio_python-0.3.2 → tecio_python-0.3.3}/tests/test_cli.py +0 -0
  43. {tecio_python-0.3.2 → tecio_python-0.3.3}/tests/test_libtecio.py +0 -0
  44. {tecio_python-0.3.2 → tecio_python-0.3.3}/tests/test_read.py +0 -0
  45. {tecio_python-0.3.2 → tecio_python-0.3.3}/tests/test_write.py +0 -0
@@ -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/
@@ -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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tecio-python"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Python interface for reading and writing Tecplot data files"
9
9
 
10
10
  readme = "README.md"
@@ -70,7 +70,8 @@ include = ["tecio*"]
70
70
  exclude = ["archive*", "test*"]
71
71
 
72
72
  [tool.setuptools.data-files]
73
- "share/tecio/plugins/paraview" = ["plugins/paraview/TecplotTecioReader.py"]
73
+ "share/plugins/paraview" = ["plugins/paraview/*"]
74
+ "share/plugins/matlab" = ["plugins/matlab/*"]
74
75
 
75
76
  [tool.ruff]
76
77
  line-length = 88
@@ -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",
@@ -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