tecio-python 0.1.0__py3-none-any.whl → 0.1.1__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
@@ -12,12 +12,15 @@ except metadata.PackageNotFoundError:
12
12
  __version__ = "0.0.0"
13
13
 
14
14
  from . import cli, dat, libtecio, plt, szl, utils
15
+ from ._containers import VariableList, ZoneList
15
16
  from ._io import AppendReadWrite, AppendWrite, open
16
17
 
17
18
  # Ensure tecio.open displays as the canonical public name in docs and help().
18
19
  open.__module__ = "tecio"
19
20
  AppendWrite.__module__ = "tecio"
20
21
  AppendReadWrite.__module__ = "tecio"
22
+ ZoneList.__module__ = "tecio"
23
+ VariableList.__module__ = "tecio"
21
24
 
22
25
  __all__ = [
23
26
  "libtecio",
@@ -29,5 +32,7 @@ __all__ = [
29
32
  "cli",
30
33
  "AppendWrite",
31
34
  "AppendReadWrite",
35
+ "ZoneList",
36
+ "VariableList",
32
37
  "__version__",
33
38
  ]
tecio/_containers.py ADDED
@@ -0,0 +1,290 @@
1
+ """Index- and name-based container types for Tecplot data collections.
2
+
3
+ These containers are shared by the ``tecio`` readers: ``Read.zone`` returns a
4
+ :class:`ZoneList` of ``ReadZone`` and ``ReadZone.variable`` returns a
5
+ :class:`VariableList` of ``ReadVariable`` for every supported format (SZL, PLT, DAT).
6
+ They depend only on small structural protocols (``.name`` for variables, ``.variable``
7
+ for zones) so they import nothing from either hierarchy and cannot introduce a circular
8
+ dependency.
9
+
10
+ Access model:
11
+
12
+ reader.zone # ZoneList
13
+ reader.zone[0] # ReadZone (element)
14
+ reader.zone[1:4] # ZoneList (sub-collection, same kind)
15
+ reader.zone[0].variable # VariableList
16
+ reader.zone[0].variable["x"] # ReadVariable (object: .values, .is_passive)
17
+ reader.zone[0].variable[2] # ReadVariable (0-based index)
18
+
19
+ Subscripting always returns an element or a sub-collection *of the same kind* (never a
20
+ raw array). The underlying NumPy data is pulled with ``get_array`` on a single zone,
21
+ which mirrors the pandas ``df[...]`` split: a scalar key returns one array, a list of
22
+ names returns a tuple of arrays (for unpacking)::
23
+
24
+ p = reader.zone[0].get_array("p") # ndarray | None
25
+ p = reader.zone[0].get_array(2) # ndarray | None (0-based index)
26
+ x, y, z = reader.zone[0].get_array(["x", "y", "z"]) # tuple, one per name
27
+
28
+ There is deliberately **no** cross-zone array accessor. To pull one variable across
29
+ many zones (e.g. a transient sequence), iterate explicitly so the outer axis is owned by
30
+ your code, and stack only when you know the result is rectangular::
31
+
32
+ seq = [z.get_array("p") for z in reader.zone] # list[ndarray | None]
33
+ stack = np.stack(seq) # only if shapes all match
34
+
35
+ Name lookup is exact and case-sensitive throughout, so distinct variables such
36
+ as ``"x"`` (a local coordinate) and ``"X"`` (a global coordinate) never
37
+ collide, and names that are not valid Python identifiers (``"X [ft]"``,
38
+ ``"p'"``) resolve like any other key.
39
+
40
+ Note:
41
+ ``get_array`` returns ``None`` for passive or shared variables, mirroring
42
+ ``ReadVariable.values``. A list of length 1 (``get_array(["p"])``) returns a 1-tuple
43
+ ``(array,)``, not a bare array, sequence-in always yields tuple-out, matching
44
+ ``df[["x"]]`` staying 2-D. A missing variable *name* raises ``KeyError``; an
45
+ out-of-range *index* raises ``IndexError``.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from collections.abc import Iterator
51
+ from typing import Any, Generic, Protocol, TypeVar, overload
52
+
53
+ import numpy as np
54
+ import numpy.typing as npt
55
+
56
+ # --------------------------------------------------------------------------------------
57
+ # Structural protocols
58
+ # --------------------------------------------------------------------------------------
59
+
60
+
61
+ class _HasName(Protocol):
62
+ """Minimal protocol satisfied by every format's variable element."""
63
+
64
+ @property
65
+ def name(self) -> str: ...
66
+
67
+
68
+ class _HasNameAndValues(Protocol):
69
+ """A variable element that can surface both its name and its data array."""
70
+
71
+ @property
72
+ def name(self) -> str: ...
73
+ @property
74
+ def values(self) -> npt.NDArray | None: ...
75
+
76
+
77
+ class _HasVariableList(Protocol):
78
+ """A zone element exposing a name/index-addressable variable container."""
79
+
80
+ @property
81
+ def variable(self) -> VariableList[Any]: ...
82
+
83
+
84
+ _VarT = TypeVar("_VarT", bound=_HasName)
85
+ _ZoneT = TypeVar("_ZoneT", bound=_HasVariableList)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Shared dispatch helper
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ @overload
94
+ def select_variable_arrays(
95
+ variables: VariableList[Any], key: int | str
96
+ ) -> npt.NDArray | None: ...
97
+ @overload
98
+ def select_variable_arrays(
99
+ variables: VariableList[Any], key: list[str]
100
+ ) -> tuple[npt.NDArray | None, ...]: ...
101
+
102
+
103
+ def select_variable_arrays(
104
+ variables: VariableList[Any],
105
+ key: int | str | list[str],
106
+ ) -> npt.NDArray | None | tuple[npt.NDArray | None, ...]:
107
+ """Resolve *key* against *variables* and return the underlying array(s).
108
+
109
+ Backs ``ReadZone.get_array`` (and any future ``Zone.get_array``) so the
110
+ scalar-vs-sequence dispatch is defined exactly once.
111
+
112
+ Args:
113
+ variables: The zone's :class:`VariableList`.
114
+ key: A single 0-based index or exact name (→ one array), or a list of exact
115
+ names (→ a tuple of arrays, in the order given).
116
+
117
+ Returns:
118
+ One array (or ``None`` for a passive/shared variable) for a scalar key; a tuple
119
+ of such arrays for a list of names.
120
+
121
+ Raises:
122
+ KeyError: If a name does not exist.
123
+ IndexError: If an index is out of range.
124
+ """
125
+ if isinstance(key, np.integer):
126
+ key = int(key)
127
+ if isinstance(key, (str, int)):
128
+ return variables[key].values
129
+ return tuple(variables[k].values for k in key)
130
+
131
+
132
+ # ======================================================================================
133
+ # VariableList
134
+ # ======================================================================================
135
+
136
+
137
+ class VariableList(Generic[_VarT]):
138
+ """Read-only sequence of variables with positional *and* named access.
139
+
140
+ Drop-in for the ``list`` previously returned by ``ReadZone.variable``:
141
+ iteration, ``len()``, and integer indexing are unchanged. A string key
142
+ resolves a variable by its exact, case-sensitive name.
143
+
144
+ Subscripting returns the variable *object*; use its ``.values`` (or the
145
+ zone's ``get_array``) to obtain the underlying array.
146
+
147
+ Args:
148
+ variables: Ordered list of variable elements (each exposing ``.name``).
149
+
150
+ Note:
151
+ If two variables share a name (rare), the first occurrence wins for
152
+ name-based lookup.
153
+ """
154
+
155
+ __slots__ = ("_items", "_name_index")
156
+
157
+ def __init__(self, variables: list[_VarT]) -> None:
158
+ self._items: list[_VarT] = variables
159
+ # Built lazily on first name lookup so purely positional use pays no
160
+ # cost. For SZL this also avoids issuing a C call per variable name
161
+ # until a name is actually requested.
162
+ self._name_index: dict[str, int] | None = None
163
+
164
+ def _index(self) -> dict[str, int]:
165
+ """Return (building once) the ``name -> position`` lookup table."""
166
+ if self._name_index is None:
167
+ index: dict[str, int] = {}
168
+ for i, var in enumerate(self._items):
169
+ index.setdefault(var.name, i) # first occurrence wins
170
+ self._name_index = index
171
+ return self._name_index
172
+
173
+ def __len__(self) -> int:
174
+ return len(self._items)
175
+
176
+ def __iter__(self) -> Iterator[_VarT]:
177
+ return iter(self._items)
178
+
179
+ def __getitem__(self, key: int | str) -> _VarT:
180
+ """Return a variable object by 0-based index or exact name.
181
+
182
+ Args:
183
+ key: A 0-based integer index or an exact, case-sensitive variable
184
+ name.
185
+
186
+ Returns:
187
+ The matching variable element.
188
+
189
+ Raises:
190
+ KeyError: If *key* is a name that does not exist.
191
+ IndexError: If *key* is an out-of-range index.
192
+ """
193
+ if isinstance(key, str):
194
+ try:
195
+ return self._items[self._index()[key]]
196
+ except KeyError:
197
+ raise KeyError(
198
+ f"No variable named {key!r}. Available: {self.names()}"
199
+ ) from None
200
+ return self._items[key]
201
+
202
+ def __contains__(self, key: object) -> bool:
203
+ if isinstance(key, str):
204
+ return key in self._index()
205
+ return key in self._items
206
+
207
+ def names(self) -> list[str]:
208
+ """Return the variable names in dataset order."""
209
+ return [var.name for var in self._items]
210
+
211
+ def __repr__(self) -> str:
212
+ n = len(self._items)
213
+ if n == 0:
214
+ return "VariableList([])"
215
+ head = 20
216
+ lines = [f" {v!r}," for v in self._items[:head]]
217
+ if n > head:
218
+ more = n - head
219
+ lines.append(f" ... and {more} more variable{'' if more == 1 else 's'},")
220
+ body = "\n".join(lines)
221
+ return f"VariableList([\n{body}\n])"
222
+
223
+
224
+ # ======================================================================================
225
+ # ZoneList
226
+ # ======================================================================================
227
+
228
+
229
+ class ZoneList(Generic[_ZoneT]):
230
+ """Read-only sequence of zones: positional access and slicing only.
231
+
232
+ Drop-in for the ``list`` previously returned by ``Read.zone``: iteration, ``len()``,
233
+ and integer indexing are unchanged. Slicing returns another :class:`ZoneList` (not a
234
+ plain ``list``) so navigation composes.
235
+
236
+ This container deliberately exposes **no** data-extraction method. Pulling one
237
+ variable across many zones is an explicit loop over the zones, keeping the outer
238
+ (zone) axis owned by the caller (see the module docs).
239
+
240
+ Args:
241
+ zones: Ordered list of zone elements (each exposing ``.variable``).
242
+ """
243
+
244
+ __slots__ = ("_items",)
245
+
246
+ def __init__(self, zones: list[_ZoneT]) -> None:
247
+ self._items: list[_ZoneT] = zones
248
+
249
+ def __len__(self) -> int:
250
+ return len(self._items)
251
+
252
+ def __iter__(self) -> Iterator[_ZoneT]:
253
+ return iter(self._items)
254
+
255
+ def __contains__(self, item: object) -> bool:
256
+ return item in self._items
257
+
258
+ @overload
259
+ def __getitem__(self, key: int) -> _ZoneT: ...
260
+ @overload
261
+ def __getitem__(self, key: slice) -> ZoneList[_ZoneT]: ...
262
+
263
+ def __getitem__(self, key: int | slice) -> _ZoneT | ZoneList[_ZoneT]:
264
+ """Index a single zone or slice a sub-collection of zones.
265
+
266
+ Args:
267
+ key: A 0-based zone index, or a ``slice``.
268
+
269
+ Returns:
270
+ A zone element (``int`` key) or a new :class:`ZoneList` (``slice``
271
+ key).
272
+
273
+ Raises:
274
+ IndexError: If an integer *key* is out of range.
275
+ """
276
+ if isinstance(key, slice):
277
+ return ZoneList(self._items[key])
278
+ return self._items[key]
279
+
280
+ def __repr__(self) -> str:
281
+ n = len(self._items)
282
+ if n == 0:
283
+ return "ZoneList([])"
284
+ head = 5
285
+ lines = [f" {z!r}," for z in self._items[:head]]
286
+ if n > head:
287
+ more = n - head
288
+ lines.append(f" ... and {more} more zone{'' if more == 1 else 's'},")
289
+ body = "\n".join(lines)
290
+ return f"ZoneList([\n{body}\n])"