vcti-array-display 1.1.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.
@@ -0,0 +1,19 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """vcti.arraydisplay — Presentation layer for NumPy structured arrays."""
4
+
5
+ from importlib.metadata import version
6
+
7
+ from ._exclusion import FILLER_COLUMNS, LENGTH_COLUMNS, VOID_COLUMNS, ColumnExclusion
8
+ from .array_display import ArrayDisplay
9
+
10
+ __version__ = version("vcti-array-display")
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "ArrayDisplay",
15
+ "ColumnExclusion",
16
+ "FILLER_COLUMNS",
17
+ "LENGTH_COLUMNS",
18
+ "VOID_COLUMNS",
19
+ ]
@@ -0,0 +1,60 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Column exclusion rules and pre-built patterns.
4
+
5
+ Provides the generic exclusion mechanism used by ArrayDisplay to filter
6
+ columns by name, regex, or a callable predicate that receives both the
7
+ column name and its dtype.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Callable, Sequence
14
+
15
+ import numpy as np
16
+
17
+ ColumnExclusion = str | re.Pattern[str] | Callable[[str, np.dtype], bool]
18
+ """A single exclusion rule:
19
+
20
+ - ``str`` — exact column name match.
21
+ - ``re.Pattern`` — regex matched against the column name.
22
+ - ``callable(name: str, dtype: np.dtype) -> bool`` — predicate receiving
23
+ the column name *and* its dtype, for filtering based on type, shape,
24
+ or any combination.
25
+ """
26
+
27
+ FILLER_COLUMNS: re.Pattern[str] = re.compile(r"^f\d+$")
28
+ """Matches C++ memory-alignment filler fields by name (f0, f3, f123, …)."""
29
+
30
+ LENGTH_COLUMNS: re.Pattern[str] = re.compile(r"_len$")
31
+ """Matches internal string-length columns by name (label_len, name_len, …)."""
32
+
33
+
34
+ def VOID_COLUMNS(name: str, dtype: np.dtype) -> bool:
35
+ """Exclude all void-dtype fields (C++ alignment padding by dtype)."""
36
+ return dtype.kind == "V"
37
+
38
+
39
+ def is_excluded(name: str, dtype: np.dtype, rules: Sequence[ColumnExclusion]) -> bool:
40
+ """Test whether a column name/dtype matches any exclusion rule.
41
+
42
+ Args:
43
+ name: Column name to test.
44
+ dtype: Column's dtype.
45
+ rules: Sequence of exclusion rules.
46
+
47
+ Returns:
48
+ True if the column should be excluded.
49
+ """
50
+ for rule in rules:
51
+ if isinstance(rule, str):
52
+ if name == rule:
53
+ return True
54
+ elif isinstance(rule, re.Pattern):
55
+ if rule.search(name) is not None:
56
+ return True
57
+ elif callable(rule):
58
+ if rule(name, dtype):
59
+ return True
60
+ return False
@@ -0,0 +1,46 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Dtype flattening helpers.
4
+
5
+ Maps original multi-component fields to their flattened scalar column names
6
+ after ``flatten_record_dtype`` has expanded vector/matrix fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import numpy as np
14
+
15
+
16
+ def build_field_components(
17
+ original_dtype: np.dtype,
18
+ flattened_dtype: np.dtype,
19
+ ) -> dict[str, list[str]]:
20
+ """Map original multi-component fields to their flattened column names.
21
+
22
+ Walks both dtypes positionally: scalar fields consume one slot,
23
+ vector/matrix fields consume ``prod(shape)`` slots.
24
+
25
+ Args:
26
+ original_dtype: Dtype before flattening.
27
+ flattened_dtype: Dtype after flattening.
28
+
29
+ Returns:
30
+ Dict mapping original field name to list of flattened column names.
31
+ Only includes fields that were actually expanded (shape != ()).
32
+ """
33
+ if flattened_dtype.names is None:
34
+ raise TypeError("flattened_dtype must be a structured dtype with named fields")
35
+ if original_dtype.names is None:
36
+ raise TypeError("original_dtype must be a structured dtype with named fields")
37
+ flat_names = list(flattened_dtype.names)
38
+ result: dict[str, list[str]] = {}
39
+ idx = 0
40
+ for name in original_dtype.names:
41
+ shape = original_dtype[name].shape
42
+ n_components = max(math.prod(shape), 1)
43
+ if n_components > 1:
44
+ result[name] = flat_names[idx : idx + n_components]
45
+ idx += n_components
46
+ return result
@@ -0,0 +1,45 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Value formatting and pandas import helpers for adapters."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import types
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+
13
+ def format_value(value: Any) -> str:
14
+ """Format a single array value for text display.
15
+
16
+ Handles floats (6 significant digits), integers, bytes, and strings.
17
+ """
18
+ if isinstance(value, (np.floating, float)):
19
+ if np.isnan(value):
20
+ return "NaN"
21
+ return f"{value:.6g}"
22
+ if isinstance(value, (np.integer, int)):
23
+ return str(int(value))
24
+ if isinstance(value, (bytes, np.bytes_)):
25
+ return value.decode(errors="replace")
26
+ return str(value)
27
+
28
+
29
+ def import_pandas() -> types.ModuleType:
30
+ """Import pandas or raise a helpful error.
31
+
32
+ Returns:
33
+ The pandas module.
34
+
35
+ Raises:
36
+ ImportError: If pandas is not installed.
37
+ """
38
+ try:
39
+ import pandas as pd
40
+ except ImportError:
41
+ raise ImportError(
42
+ "pandas is required for this method. "
43
+ "Install with: pip install vcti-array-display[pandas]"
44
+ ) from None
45
+ return pd
@@ -0,0 +1,624 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """ArrayDisplay — presentation layer for NumPy structured arrays.
4
+
5
+ Provides five capabilities over raw structured arrays:
6
+
7
+ 1. **Column filtering** — hide columns by name, regex, or predicate.
8
+ 2. **Dtype flattening** — expand vector/matrix fields into scalar columns
9
+ with user-controllable component names.
10
+ 3. **Enum mapping** — lazily map integer ID columns to human-readable names.
11
+ 4. **Array slicing** — create lightweight index arrays for bounded access.
12
+ 5. **Adapters** — render as text table or pandas DataFrame on bounded slices.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import copy
18
+ from collections.abc import Sequence
19
+ from types import MappingProxyType
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+ from vcti.nputils import flatten_record_dtype, join_struct_arrays
24
+
25
+ from ._exclusion import ColumnExclusion, is_excluded
26
+ from ._flattening import build_field_components
27
+ from ._formatting import format_value, import_pandas
28
+
29
+
30
+ class ArrayDisplay:
31
+ """Presentation layer for NumPy structured arrays.
32
+
33
+ Wraps a structured array reference (no copy) and records how it should
34
+ be presented:
35
+
36
+ 1. **Column filtering** — ``exclude_columns`` hides fields by name,
37
+ regex, or callable. Pre-built patterns ``FILLER_COLUMNS`` and
38
+ ``LENGTH_COLUMNS`` cover common C++ interop noise.
39
+ 2. **Dtype flattening** — ``flatten_dtype`` expands vector/matrix fields
40
+ into scalar columns. ``set_component_names()`` lets you replace
41
+ the default numeric suffixes (``_0``, ``_1``) with meaningful labels
42
+ (``_x``, ``_y``, ``_z``).
43
+ 3. **Enum mapping** — ``add_name_columns()`` stores a recipe mapping
44
+ integer IDs to strings. Resolution is lazy — applied only when
45
+ a bounded slice is presented.
46
+ 4. **Array slicing** — ``to_slice()`` returns a lightweight index array
47
+ for bounded access without copying the full data.
48
+ 5. **Adapters** — ``to_table()`` (pure numpy) and ``to_dataframe()``
49
+ (optional pandas) render bounded slices with all mappings applied.
50
+
51
+ Attributes:
52
+ array: The underlying structured array (reference, not copy).
53
+ original_dtype: Dtype before any flattening.
54
+ view_columns: Ordered list of user-visible column names (read-only copy).
55
+ name_columns: Enum mapping recipes (read-only view).
56
+ field_components: Flattened field grouping (read-only view).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ src_obj: np.ndarray | None = None,
62
+ column_list: list[str] | None = None,
63
+ flatten_dtype: bool = False,
64
+ exclude_columns: Sequence[ColumnExclusion] | None = None,
65
+ component_names: dict[str, list[str]] | None = None,
66
+ ) -> None:
67
+ """Initialize an ArrayDisplay.
68
+
69
+ Args:
70
+ src_obj: NumPy structured array, or None for empty.
71
+ column_list: Explicit columns to show. If None, all columns
72
+ minus those matching *exclude_columns*.
73
+ flatten_dtype: Expand vector/matrix fields into scalar columns.
74
+ exclude_columns: Rules for hiding columns — each element is a
75
+ ``str`` (exact), ``re.Pattern`` (regex), or callable.
76
+ component_names: Custom suffixes for flattened fields, e.g.
77
+ ``{'position': ['x', 'y', 'z']}`` renames ``position_0``
78
+ through ``position_2`` to ``position_x`` through ``position_z``.
79
+ Only meaningful when *flatten_dtype* is True.
80
+ """
81
+ self.array: np.ndarray | None = None
82
+ self.original_dtype: np.dtype | None = None
83
+ self._view_columns: list[str] = []
84
+ self._name_columns: dict[str, tuple[str, dict[int, str]]] = {}
85
+ self._field_components: dict[str, list[str]] = {}
86
+ # Maps display names to actual dtype column names, used when
87
+ # set_component_names() renames flattened columns. Survives
88
+ # multiple renames (e.g. x→lat) by always tracking back to
89
+ # the original dtype column.
90
+ self._column_aliases: dict[str, str] = {}
91
+
92
+ if isinstance(src_obj, np.ndarray):
93
+ self.set_array(src_obj, column_list, flatten_dtype, exclude_columns, component_names)
94
+ elif src_obj is not None:
95
+ raise TypeError(f"Expected np.ndarray or None, got {type(src_obj).__name__}")
96
+
97
+ # -- Read-only properties ---------------------------------------------------
98
+
99
+ @property
100
+ def view_columns(self) -> list[str]:
101
+ """Ordered list of user-visible column names (defensive copy)."""
102
+ return list(self._view_columns)
103
+
104
+ @property
105
+ def name_columns(self) -> MappingProxyType[str, tuple[str, dict[int, str]]]:
106
+ """Enum mapping recipes ``{name: (id_col, {int: str})}`` (read-only view)."""
107
+ return MappingProxyType(self._name_columns)
108
+
109
+ @property
110
+ def field_components(self) -> MappingProxyType[str, list[str]]:
111
+ """Flattened field grouping ``{field: [col, ...]}`` (read-only view)."""
112
+ return MappingProxyType(self._field_components)
113
+
114
+ # -- 1. Column filtering ----------------------------------------------------
115
+
116
+ def set_view_columns(
117
+ self,
118
+ column_list: list[str] | None = None,
119
+ exclude_columns: Sequence[ColumnExclusion] | None = None,
120
+ ) -> None:
121
+ """Configure which columns are visible.
122
+
123
+ Args:
124
+ column_list: Explicit columns. If None, use all array columns
125
+ minus those matching *exclude_columns*.
126
+ exclude_columns: Rules for hiding columns.
127
+ """
128
+ if self.array is None:
129
+ self._view_columns = []
130
+ return
131
+
132
+ if column_list is not None:
133
+ self._view_columns = list(column_list)
134
+ return
135
+
136
+ if self.array.dtype.names is None:
137
+ raise TypeError("Array has no named fields")
138
+ cols = list(self.array.dtype.names)
139
+
140
+ if exclude_columns:
141
+ dt = self.array.dtype
142
+ cols = [c for c in cols if not is_excluded(c, dt[c], exclude_columns)]
143
+
144
+ self._view_columns = cols
145
+
146
+ def include_view_columns(self, column_list: list[str]) -> None:
147
+ """Add columns to the current view."""
148
+ self._view_columns.extend(column_list)
149
+
150
+ def exclude_view_columns(self, column_list: list[str]) -> None:
151
+ """Remove columns from the current view."""
152
+ exclude = set(column_list)
153
+ self._view_columns = [c for c in self._view_columns if c not in exclude]
154
+
155
+ def replace_view_columns(self, mapping: dict[str, str]) -> None:
156
+ """Replace column names in the view.
157
+
158
+ Args:
159
+ mapping: Dict mapping old_name -> new_name.
160
+ """
161
+ for i, col in enumerate(self._view_columns):
162
+ if col in mapping:
163
+ self._view_columns[i] = mapping[col]
164
+
165
+ # -- 2. Dtype flattening ----------------------------------------------------
166
+
167
+ def set_array(
168
+ self,
169
+ src_array: np.ndarray,
170
+ column_list: list[str] | None = None,
171
+ flatten_dtype: bool = False,
172
+ exclude_columns: Sequence[ColumnExclusion] | None = None,
173
+ component_names: dict[str, list[str]] | None = None,
174
+ ) -> None:
175
+ """Set or replace the internal array.
176
+
177
+ Args:
178
+ src_array: NumPy structured array with named fields.
179
+ column_list: Explicit columns to show.
180
+ flatten_dtype: Expand vector/matrix fields into scalar columns.
181
+ exclude_columns: Rules for hiding columns.
182
+ component_names: Custom suffixes for flattened fields.
183
+
184
+ Raises:
185
+ TypeError: If *src_array* is not a structured array.
186
+ """
187
+ if src_array.dtype.names is None:
188
+ raise TypeError(
189
+ f"Expected a structured array with named fields, got dtype {src_array.dtype}"
190
+ )
191
+
192
+ self.original_dtype = src_array.dtype
193
+ self._field_components = {}
194
+ self._name_columns = {}
195
+ self._column_aliases = {}
196
+
197
+ if flatten_dtype and src_array.ndim == 1:
198
+ flattened_dt, _ = flatten_record_dtype(src_array.dtype)
199
+ self.array = src_array.view(flattened_dt)
200
+ self._field_components = build_field_components(src_array.dtype, flattened_dt)
201
+ else:
202
+ self.array = src_array
203
+
204
+ self.set_view_columns(column_list, exclude_columns)
205
+
206
+ if component_names:
207
+ for field, names in component_names.items():
208
+ self.set_component_names(field, names)
209
+
210
+ def set_component_names(self, field: str, names: list[str]) -> None:
211
+ """Rename flattened component columns for a multi-component field.
212
+
213
+ After dtype flattening, a field like ``position(3,)`` becomes
214
+ ``position_0, position_1, position_2``. This method renames them
215
+ to ``position_x, position_y, position_z`` (given ``names=['x','y','z']``).
216
+
217
+ Args:
218
+ field: Original field name (before flattening).
219
+ names: New suffixes — one per component.
220
+
221
+ Raises:
222
+ ValueError: If *field* is not in ``field_components`` or the
223
+ number of names doesn't match the component count.
224
+ """
225
+ if field not in self._field_components:
226
+ raise ValueError(
227
+ f"Field '{field}' has no flattened components. "
228
+ f"Available: {list(self._field_components)}"
229
+ )
230
+
231
+ current = self._field_components[field]
232
+ if len(names) != len(current):
233
+ raise ValueError(
234
+ f"Field '{field}' has {len(current)} components, got {len(names)} names"
235
+ )
236
+
237
+ rename = {}
238
+ new_cols = []
239
+ for old_col, suffix in zip(current, names):
240
+ new_col = f"{field}_{suffix}"
241
+ rename[old_col] = new_col
242
+ new_cols.append(new_col)
243
+ # Track the actual dtype column name for data access
244
+ dtype_col = self._column_aliases.get(old_col, old_col)
245
+ self._column_aliases[new_col] = dtype_col
246
+ self._column_aliases.pop(old_col, None)
247
+
248
+ self.replace_view_columns(rename)
249
+ self._field_components[field] = new_cols
250
+
251
+ # -- 3. Enum mapping --------------------------------------------------------
252
+
253
+ def add_name_columns(self, name_columns: dict[str, tuple[str, dict[int, str]]]) -> None:
254
+ """Register enum ID -> name mappings (lazy — resolved at presentation).
255
+
256
+ Stores a recipe for each mapping. The actual resolution happens
257
+ only when ``to_table()`` or ``to_dataframe()`` processes a bounded
258
+ slice. Automatically replaces ID columns with name columns in
259
+ ``view_columns``.
260
+
261
+ Args:
262
+ name_columns: ``{name_column: (id_column, {int: str})}``.
263
+
264
+ Example:
265
+ >>> view.add_name_columns({
266
+ ... 'element_type_name': ('element_type', {1: 'QUAD', 2: 'HEX'}),
267
+ ... })
268
+ """
269
+ self._name_columns.update(name_columns)
270
+ replacement_map = {v[0]: k for k, v in name_columns.items()}
271
+ self.replace_view_columns(replacement_map)
272
+
273
+ def _resolve_column_values(self, col: str, indices: np.ndarray) -> np.ndarray:
274
+ """Resolve column values for a bounded set of row indices.
275
+
276
+ Regular columns return values directly. Name columns map integer
277
+ IDs to strings via the stored recipe. Uses a plain list
278
+ comprehension — faster than ``np.vectorize`` for the small slices
279
+ that presentation methods operate on.
280
+ """
281
+ if self.array is None:
282
+ raise ValueError("No array set")
283
+ if col in self._name_columns:
284
+ id_col, mapping = self._name_columns[col]
285
+ id_values = self.array[id_col][indices]
286
+ return np.array([mapping.get(int(x), str(x)) for x in id_values])
287
+ dtype_col = self._column_aliases.get(col, col)
288
+ return self.array[dtype_col][indices]
289
+
290
+ # -- 4. Array slicing -------------------------------------------------------
291
+
292
+ def to_slice(
293
+ self,
294
+ *,
295
+ head: int | None = None,
296
+ tail: int | None = None,
297
+ mask: np.ndarray | None = None,
298
+ indices: np.ndarray | None = None,
299
+ ) -> np.ndarray:
300
+ """Create an index array for bounded access.
301
+
302
+ Returns a lightweight integer index array suitable for fancy
303
+ indexing: ``view.array[view.to_slice(head=20)]``.
304
+
305
+ Exactly one slicing strategy should be provided. Priority when
306
+ multiple are given: *indices* > *mask* > *head*/*tail*.
307
+
308
+ Args:
309
+ head: Number of rows from the start.
310
+ tail: Number of rows from the end.
311
+ mask: Boolean mask array.
312
+ indices: Explicit integer index array (returned as-is).
313
+
314
+ Returns:
315
+ Integer index array (dtype ``np.intp``).
316
+
317
+ Raises:
318
+ ValueError: If no array is set, or if conflicting parameters
319
+ are provided (e.g. *mask* with *head*/*tail*).
320
+ """
321
+ if self.array is None:
322
+ raise ValueError("No array set")
323
+
324
+ n = len(self.array)
325
+
326
+ # Validate parameter combinations
327
+ has_head_tail = head is not None or tail is not None
328
+ if indices is not None and (mask is not None or has_head_tail):
329
+ raise ValueError("Cannot combine 'indices' with other slicing parameters")
330
+ if mask is not None and has_head_tail:
331
+ raise ValueError("Cannot combine 'mask' with 'head'/'tail'")
332
+
333
+ if indices is not None:
334
+ return indices
335
+
336
+ if mask is not None:
337
+ return np.where(mask)[0]
338
+
339
+ if head is not None and tail is not None:
340
+ h = np.arange(min(head, n))
341
+ t = np.arange(max(0, n - tail), n)
342
+ return np.unique(np.concatenate([h, t]))
343
+
344
+ if head is not None:
345
+ return np.arange(min(head, n))
346
+
347
+ if tail is not None:
348
+ return np.arange(max(0, n - tail), n)
349
+
350
+ return np.arange(n)
351
+
352
+ # -- 5. Adapters ------------------------------------------------------------
353
+
354
+ def to_table(
355
+ self,
356
+ *,
357
+ head: int = 20,
358
+ tail: int = 5,
359
+ max_col_width: int = 30,
360
+ indices: np.ndarray | None = None,
361
+ ) -> str:
362
+ """Render a text table from a bounded slice (pure numpy).
363
+
364
+ Resolves enum name columns and formats the output as an aligned
365
+ plain-text table. Only the requested rows are processed.
366
+
367
+ Args:
368
+ head: Rows from the start (ignored if *indices* given).
369
+ tail: Rows from the end (ignored if *indices* given).
370
+ max_col_width: Maximum column display width in characters.
371
+ indices: Explicit row indices (overrides head/tail).
372
+
373
+ Returns:
374
+ Formatted text table string.
375
+ """
376
+ if self.array is None or not self._view_columns:
377
+ return repr(self)
378
+
379
+ n = len(self.array)
380
+ cols = self._view_columns
381
+
382
+ # Determine row blocks and whether to show an ellipsis separator
383
+ if indices is not None:
384
+ blocks: list[np.ndarray | None] = [indices]
385
+ elif n > head + tail:
386
+ blocks = [
387
+ np.arange(min(head, n)),
388
+ None, # ellipsis marker
389
+ np.arange(max(0, n - tail), n),
390
+ ]
391
+ else:
392
+ blocks = [np.arange(n)]
393
+
394
+ # Resolve column values per block and convert to strings
395
+ str_rows: list[list[str] | None] = []
396
+ for block in blocks:
397
+ if block is None:
398
+ str_rows.append(None)
399
+ continue
400
+ col_values = {col: self._resolve_column_values(col, block) for col in cols}
401
+ for i in range(len(block)):
402
+ str_rows.append([format_value(col_values[col][i]) for col in cols])
403
+
404
+ # Calculate column widths
405
+ col_widths: list[int] = []
406
+ for ci, col in enumerate(cols):
407
+ max_val = max(
408
+ (len(row[ci]) for row in str_rows if row is not None),
409
+ default=0,
410
+ )
411
+ col_widths.append(min(max(len(col), max_val), max_col_width))
412
+
413
+ # Determine numeric alignment per column
414
+ dt = self.array.dtype
415
+ dtype_names = set(dt.names or ())
416
+
417
+ def _is_numeric(col: str) -> bool:
418
+ if col in self._name_columns:
419
+ return False
420
+ actual = self._column_aliases.get(col, col)
421
+ return actual in dtype_names and np.issubdtype(dt[actual], np.number)
422
+
423
+ col_numeric = [_is_numeric(col) for col in cols]
424
+
425
+ def fmt(val: str, ci: int) -> str:
426
+ w = col_widths[ci]
427
+ if len(val) > w:
428
+ val = val[: w - 1] + "\u2026"
429
+ return val.rjust(w) if col_numeric[ci] else val.ljust(w)
430
+
431
+ sep = " | "
432
+ lines: list[str] = []
433
+
434
+ # Header
435
+ lines.append(
436
+ sep.join(col.ljust(col_widths[ci])[: col_widths[ci]] for ci, col in enumerate(cols))
437
+ )
438
+ lines.append("-+-".join("-" * w for w in col_widths))
439
+
440
+ # Data rows
441
+ for row in str_rows:
442
+ if row is None:
443
+ lines.append(sep.join("...".center(col_widths[ci]) for ci in range(len(cols))))
444
+ else:
445
+ lines.append(sep.join(fmt(row[ci], ci) for ci in range(len(cols))))
446
+
447
+ lines.append(f"\n[{n} rows x {len(cols)} columns]")
448
+ return "\n".join(lines)
449
+
450
+ def to_dataframe(
451
+ self,
452
+ *,
453
+ indices: np.ndarray | None = None,
454
+ head: int | None = None,
455
+ tail: int | None = None,
456
+ resolve_names: bool = True,
457
+ ) -> Any:
458
+ """Convert a bounded slice to a pandas DataFrame.
459
+
460
+ Enum name columns are materialized using ``pd.Categorical`` for
461
+ memory efficiency.
462
+
463
+ Args:
464
+ indices: Explicit row indices.
465
+ head: Rows from the start.
466
+ tail: Rows from the end.
467
+ resolve_names: If True, materialize enum name columns.
468
+ If False, show raw ID columns instead.
469
+
470
+ Returns:
471
+ pandas DataFrame with the configured view columns.
472
+
473
+ Raises:
474
+ ImportError: If pandas is not installed.
475
+ """
476
+ pd = import_pandas()
477
+
478
+ if self.array is None:
479
+ return pd.DataFrame()
480
+
481
+ if indices is None and (head is not None or tail is not None):
482
+ indices = self.to_slice(head=head, tail=tail)
483
+
484
+ arr = self.array if indices is None else self.array[indices]
485
+ df = pd.DataFrame(arr)
486
+
487
+ cols = self._view_columns
488
+
489
+ # Rename aliased columns (from set_component_names) in the DataFrame
490
+ if self._column_aliases:
491
+ reverse = {v: k for k, v in self._column_aliases.items() if k in cols}
492
+ if reverse:
493
+ df = df.rename(columns=reverse)
494
+
495
+ if resolve_names:
496
+ for name_col, (id_col, mapping) in self._name_columns.items():
497
+ if name_col in cols:
498
+ df[name_col] = pd.Categorical(df[id_col].map(mapping))
499
+ return df[cols]
500
+
501
+ # resolve_names=False: swap name columns back to source ID columns
502
+ select = [self._name_columns[c][0] if c in self._name_columns else c for c in cols]
503
+ return df[select]
504
+
505
+ def _repr_html_(self) -> str | None:
506
+ """Rich HTML display for Jupyter notebooks."""
507
+ if self.array is None or not self._view_columns:
508
+ return None
509
+
510
+ try:
511
+ pd = import_pandas()
512
+ except ImportError:
513
+ return f"<pre>{self.to_table()}</pre>"
514
+
515
+ n = len(self.array)
516
+ max_rows = pd.get_option("display.max_rows")
517
+ if max_rows is None:
518
+ max_rows = 60
519
+
520
+ if n <= max_rows:
521
+ df = self.to_dataframe()
522
+ else:
523
+ half = max_rows // 2
524
+ head_df = self.to_dataframe(head=half)
525
+ tail_df = self.to_dataframe(tail=half)
526
+ ellipsis_data = {col: ["\u22ee"] for col in self._view_columns}
527
+ ellipsis_df = pd.DataFrame(ellipsis_data)
528
+ df = pd.concat([head_df, ellipsis_df, tail_df], ignore_index=True)
529
+
530
+ html = df.to_html(index=False, na_rep="NaN")
531
+ return f"{html}\n<p>{n} rows \u00d7 {len(self._view_columns)} columns</p>"
532
+
533
+ # -- Misc -------------------------------------------------------------------
534
+
535
+ def append_columns(
536
+ self, columns: list[np.ndarray] | tuple[np.ndarray, ...] | np.ndarray
537
+ ) -> None:
538
+ """Append additional structured columns to the array.
539
+
540
+ Joins the existing array with new structured column(s) using
541
+ ``vcti.nputils.join_struct_arrays``. The resulting dtype is the
542
+ union of all field names. Field names must not overlap — duplicate
543
+ names will raise an error from the underlying join function.
544
+
545
+ The new columns are added to the underlying array but **not**
546
+ automatically added to ``view_columns``. Call
547
+ ``include_view_columns()`` afterwards if they should be visible.
548
+
549
+ Can be called on an empty ArrayDisplay (no array set) — the
550
+ provided columns become the array.
551
+
552
+ Args:
553
+ columns: Structured array(s) to join. All arrays must have
554
+ the same number of rows as the existing array.
555
+ """
556
+ arrays: list[np.ndarray] = []
557
+ if self.array is not None:
558
+ arrays.append(self.array)
559
+
560
+ if isinstance(columns, (list, tuple)):
561
+ arrays.extend(columns)
562
+ elif isinstance(columns, np.ndarray):
563
+ arrays.append(columns)
564
+ else:
565
+ raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(columns).__name__}")
566
+
567
+ self.array = join_struct_arrays(arrays)
568
+
569
+ def copy(self) -> ArrayDisplay:
570
+ """Create a shallow copy of this ArrayDisplay.
571
+
572
+ The underlying array is shared (not copied), but all configuration
573
+ state (view columns, name columns, field components, aliases) is
574
+ independently mutable.
575
+
576
+ Returns:
577
+ A new ArrayDisplay with the same array reference and a copy
578
+ of all configuration state.
579
+ """
580
+ new = ArrayDisplay.__new__(ArrayDisplay)
581
+ new.array = self.array
582
+ new.original_dtype = self.original_dtype
583
+ new._view_columns = list(self._view_columns)
584
+ new._name_columns = copy.deepcopy(self._name_columns)
585
+ new._field_components = copy.deepcopy(self._field_components)
586
+ new._column_aliases = dict(self._column_aliases)
587
+ return new
588
+
589
+ def __len__(self) -> int:
590
+ """Return the number of rows in the array (0 if no array set)."""
591
+ if self.array is None:
592
+ return 0
593
+ return len(self.array)
594
+
595
+ def __repr__(self) -> str:
596
+ n_rows = len(self)
597
+ n_cols = len(self._view_columns)
598
+ parts = [f"rows={n_rows}", f"cols={n_cols}"]
599
+ if self._field_components:
600
+ parts.append(f"flattened={len(self._field_components)}")
601
+ if self._name_columns:
602
+ parts.append(f"enums={len(self._name_columns)}")
603
+ return f"ArrayDisplay({', '.join(parts)})"
604
+
605
+ def __str__(self) -> str:
606
+ parts = ["ArrayDisplay:"]
607
+
608
+ if self.array is not None:
609
+ parts.append(f" Shape: {self.array.shape}")
610
+ parts.append(f" Dtype: {self.array.dtype}")
611
+ parts.append(f" View columns ({len(self._view_columns)}):")
612
+ for col in self._view_columns[:10]:
613
+ parts.append(f" - {col}")
614
+ if len(self._view_columns) > 10:
615
+ parts.append(f" ... and {len(self._view_columns) - 10} more")
616
+ else:
617
+ parts.append(" Array: None")
618
+
619
+ if self._field_components:
620
+ parts.append(f" Flattened fields: {len(self._field_components)}")
621
+ if self._name_columns:
622
+ parts.append(f" Enum mappings: {len(self._name_columns)}")
623
+
624
+ return "\n".join(parts)
@@ -0,0 +1,92 @@
1
+ """Type stubs for array_display module — improved IDE support."""
2
+
3
+ from collections.abc import Sequence
4
+ from types import MappingProxyType
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from ._exclusion import ColumnExclusion
10
+
11
+ class ArrayDisplay:
12
+ array: np.ndarray | None
13
+ original_dtype: np.dtype | None
14
+
15
+ def __init__(
16
+ self,
17
+ src_obj: np.ndarray | None = ...,
18
+ column_list: list[str] | None = ...,
19
+ flatten_dtype: bool = ...,
20
+ exclude_columns: Sequence[ColumnExclusion] | None = ...,
21
+ component_names: dict[str, list[str]] | None = ...,
22
+ ) -> None: ...
23
+
24
+ # Read-only properties
25
+ @property
26
+ def view_columns(self) -> list[str]: ...
27
+ @property
28
+ def name_columns(self) -> MappingProxyType[str, tuple[str, dict[int, str]]]: ...
29
+ @property
30
+ def field_components(self) -> MappingProxyType[str, list[str]]: ...
31
+
32
+ # 1. Column filtering
33
+ def set_view_columns(
34
+ self,
35
+ column_list: list[str] | None = ...,
36
+ exclude_columns: Sequence[ColumnExclusion] | None = ...,
37
+ ) -> None: ...
38
+ def include_view_columns(self, column_list: list[str]) -> None: ...
39
+ def exclude_view_columns(self, column_list: list[str]) -> None: ...
40
+ def replace_view_columns(self, mapping: dict[str, str]) -> None: ...
41
+
42
+ # 2. Dtype flattening
43
+ def set_array(
44
+ self,
45
+ src_array: np.ndarray,
46
+ column_list: list[str] | None = ...,
47
+ flatten_dtype: bool = ...,
48
+ exclude_columns: Sequence[ColumnExclusion] | None = ...,
49
+ component_names: dict[str, list[str]] | None = ...,
50
+ ) -> None: ...
51
+ def set_component_names(self, field: str, names: list[str]) -> None: ...
52
+
53
+ # 3. Enum mapping
54
+ def add_name_columns(self, name_columns: dict[str, tuple[str, dict[int, str]]]) -> None: ...
55
+
56
+ # 4. Array slicing
57
+ def to_slice(
58
+ self,
59
+ *,
60
+ head: int | None = ...,
61
+ tail: int | None = ...,
62
+ mask: np.ndarray | None = ...,
63
+ indices: np.ndarray | None = ...,
64
+ ) -> np.ndarray: ...
65
+
66
+ # 5. Adapters
67
+ def to_table(
68
+ self,
69
+ *,
70
+ head: int = ...,
71
+ tail: int = ...,
72
+ max_col_width: int = ...,
73
+ indices: np.ndarray | None = ...,
74
+ ) -> str: ...
75
+ def to_dataframe(
76
+ self,
77
+ *,
78
+ indices: np.ndarray | None = ...,
79
+ head: int | None = ...,
80
+ tail: int | None = ...,
81
+ resolve_names: bool = ...,
82
+ ) -> pd.DataFrame: ...
83
+ def _repr_html_(self) -> str | None: ...
84
+
85
+ # Misc
86
+ def append_columns(
87
+ self, columns: list[np.ndarray] | tuple[np.ndarray, ...] | np.ndarray
88
+ ) -> None: ...
89
+ def copy(self) -> ArrayDisplay: ...
90
+ def __len__(self) -> int: ...
91
+ def __repr__(self) -> str: ...
92
+ def __str__(self) -> str: ...
File without changes
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-array-display
3
+ Version: 1.1.0
4
+ Summary: Presentation layer for NumPy structured arrays — column filtering, dtype flattening, enum mapping, array slicing, and adapters
5
+ Author: Visual Collaboration Technologies Inc.
6
+ Requires-Python: <3.15,>=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy>=1.24
10
+ Requires-Dist: vcti-nputils>=1.0.0
11
+ Provides-Extra: pandas
12
+ Requires-Dist: pandas>=2.0; extra == "pandas"
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest; extra == "test"
15
+ Requires-Dist: pytest-cov; extra == "test"
16
+ Requires-Dist: pandas>=2.0; extra == "test"
17
+ Provides-Extra: lint
18
+ Requires-Dist: ruff; extra == "lint"
19
+ Provides-Extra: typecheck
20
+ Requires-Dist: mypy; extra == "typecheck"
21
+ Requires-Dist: pandas-stubs; extra == "typecheck"
22
+ Dynamic: license-file
23
+
24
+ # Array Display
25
+
26
+ Presentation layer for NumPy structured arrays.
27
+
28
+ ArrayDisplay wraps a structured NumPy array reference (no copy) and provides
29
+ five capabilities for controlling how the data is presented:
30
+
31
+ 1. **Column filtering** — hide columns by name, regex, or predicate
32
+ 2. **Dtype flattening** — expand vector/matrix fields into scalar columns with custom component names
33
+ 3. **Enum mapping** — lazily map integer ID columns to human-readable names
34
+ 4. **Array slicing** — create lightweight index arrays for bounded access
35
+ 5. **Adapters** — render as text table or pandas DataFrame on bounded slices
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install vcti-array-display>=1.1.0
41
+ ```
42
+
43
+ With pandas support (optional):
44
+
45
+ ```bash
46
+ pip install vcti-array-display[pandas]>=1.1.0
47
+ ```
48
+
49
+ ---
50
+
51
+ ## 1. Column Filtering
52
+
53
+ Hide unwanted columns using any combination of exact names, regex patterns,
54
+ and callable predicates.
55
+
56
+ ```python
57
+ import re
58
+ import numpy as np
59
+ from vcti.arraydisplay import ArrayDisplay, FILLER_COLUMNS, LENGTH_COLUMNS, VOID_COLUMNS
60
+
61
+ dt = np.dtype([
62
+ ('id', 'i4'), ('f0', 'V4'), ('label', 'U20'),
63
+ ('label_len', 'i4'), ('value', 'f8'), ('f3', 'V8'),
64
+ ])
65
+ arr = np.zeros(10, dtype=dt)
66
+
67
+ # Pre-built patterns for C++ interop noise
68
+ view = ArrayDisplay(arr, exclude_columns=[FILLER_COLUMNS, LENGTH_COLUMNS])
69
+ view.view_columns # ['id', 'label', 'value']
70
+
71
+ # Or exclude by dtype — catches all void padding regardless of name
72
+ view = ArrayDisplay(arr, exclude_columns=[VOID_COLUMNS, LENGTH_COLUMNS])
73
+
74
+ # Mix exact names, regex, and callables
75
+ view = ArrayDisplay(arr, exclude_columns=[
76
+ FILLER_COLUMNS, # regex: ^f\d+$
77
+ LENGTH_COLUMNS, # regex: _len$
78
+ "debug_flag", # exact name
79
+ re.compile(r"^tmp_"), # custom regex
80
+ lambda name, dtype: dtype.kind == 'V', # by dtype
81
+ ])
82
+ ```
83
+
84
+ No magic defaults — `ArrayDisplay(arr)` shows all columns. Pre-built patterns
85
+ `FILLER_COLUMNS`, `LENGTH_COLUMNS`, and `VOID_COLUMNS` are opt-in.
86
+ Callables receive `(name, dtype)` for filtering by type, shape, or both.
87
+
88
+ ## 2. Dtype Flattening
89
+
90
+ Expand vector and matrix fields into individual scalar columns, with
91
+ optional user-defined component names.
92
+
93
+ ```python
94
+ dt = np.dtype([('id', 'i4'), ('position', 'f8', (3,))])
95
+ arr = np.zeros(5, dtype=dt)
96
+
97
+ # Default numeric suffixes
98
+ view = ArrayDisplay(arr, flatten_dtype=True)
99
+ view.view_columns # ['id', 'position_0', 'position_1', 'position_2']
100
+
101
+ # Custom component names
102
+ view = ArrayDisplay(arr, flatten_dtype=True,
103
+ component_names={'position': ['x', 'y', 'z']})
104
+ view.view_columns # ['id', 'position_x', 'position_y', 'position_z']
105
+
106
+ # Component grouping is tracked for consumers (e.g., header spanning)
107
+ view.field_components
108
+ # {'position': ['position_x', 'position_y', 'position_z']}
109
+
110
+ # Rename after construction
111
+ view.set_component_names('position', ['lat', 'lon', 'alt'])
112
+ ```
113
+
114
+ > **Note:** Default flattened names (e.g., `position_0`) are generated by
115
+ > `vcti-nputils` and may truncate long field names. Use `component_names`
116
+ > to ensure predictable, readable column names regardless of the defaults.
117
+
118
+ ## 3. Enum Mapping
119
+
120
+ Map integer ID columns to human-readable names. The mapping is stored as
121
+ a recipe and resolved lazily — only when presenting a bounded slice.
122
+
123
+ ```python
124
+ dt = np.dtype([('id', 'i4'), ('element_type', 'i4'), ('value', 'f8')])
125
+ arr = np.array([(1, 1, 10.5), (2, 2, 20.3), (3, 1, 15.0)], dtype=dt)
126
+ view = ArrayDisplay(arr)
127
+
128
+ view.add_name_columns({
129
+ 'element_type_name': ('element_type', {1: 'QUAD', 2: 'HEX'}),
130
+ })
131
+ view.view_columns # ['id', 'element_type_name', 'value']
132
+
133
+ # The mapping is NOT materialized yet — it's a recipe:
134
+ view.name_columns
135
+ # {'element_type_name': ('element_type', {1: 'QUAD', 2: 'HEX'})}
136
+ ```
137
+
138
+ ## 4. Array Slicing
139
+
140
+ Create lightweight index arrays for bounded access. The index array is
141
+ tiny regardless of the underlying array size.
142
+
143
+ ```python
144
+ idx = view.to_slice(head=20) # first 20 rows
145
+ idx = view.to_slice(tail=10) # last 10 rows
146
+ idx = view.to_slice(head=10, tail=5) # first 10 + last 5
147
+ idx = view.to_slice(mask=arr['value'] > 50) # boolean filter
148
+ idx = view.to_slice(indices=np.array([0, 100, 999])) # explicit
149
+
150
+ view.array[idx] # sliced data
151
+ ```
152
+
153
+ ## 5. Adapters
154
+
155
+ ### Text table (pure numpy — no extra dependencies)
156
+
157
+ ```python
158
+ print(view.to_table(head=10, tail=5))
159
+ # id | element_type_name | value
160
+ # ---+-------------------+------
161
+ # 1 | QUAD | 10.5
162
+ # 2 | HEX | 20.3
163
+ # ...
164
+ # [1000 rows x 3 columns]
165
+ ```
166
+
167
+ ### pandas DataFrame (optional dependency)
168
+
169
+ ```python
170
+ df = view.to_dataframe() # full array
171
+ df = view.to_dataframe(head=100) # first 100 rows
172
+ df = view.to_dataframe(resolve_names=False) # raw ID columns
173
+ ```
174
+
175
+ Enum names are materialized using `pd.Categorical` for memory efficiency.
176
+
177
+ ### Jupyter notebooks
178
+
179
+ ArrayDisplay provides `_repr_html_()` — displays a bounded row window with
180
+ enum names resolved automatically.
181
+
182
+ ---
183
+
184
+ ## Performance
185
+
186
+ Designed for large CAE arrays (millions of rows, GBs of data):
187
+
188
+ - **No array copy** — wraps a reference to the original numpy array
189
+ - **Lazy enum resolution** — resolved only on the displayed slice
190
+ - **Index arrays** — `to_slice()` returns kilobytes regardless of array size
191
+ - **Bounded presentation** — `to_table()` and `to_dataframe(head=N)` never touch the full array
192
+ - **`pd.Categorical`** — ~100x less memory than string columns for enum values
193
+
194
+ ---
195
+
196
+ ## API Summary
197
+
198
+ | Method | Pillar | Description |
199
+ |--------|--------|-------------|
200
+ | `set_view_columns(...)` | Filtering | Configure visible columns |
201
+ | `include_view_columns(cols)` | Filtering | Add columns to the view |
202
+ | `exclude_view_columns(cols)` | Filtering | Remove columns from the view |
203
+ | `replace_view_columns(mapping)` | Filtering | Rename columns in the view |
204
+ | `set_array(arr, ...)` | Flattening | Set array with optional flattening |
205
+ | `set_component_names(field, names)` | Flattening | Rename flattened components |
206
+ | `add_name_columns(mappings)` | Enum mapping | Register lazy enum mappings |
207
+ | `to_slice(...)` | Slicing | Create index array for bounded access |
208
+ | `to_table(...)` | Adapter | Render text table (pure numpy) |
209
+ | `to_dataframe(...)` | Adapter | Convert to pandas DataFrame |
210
+ | `copy()` | Misc | Shallow copy with independent config |
211
+ | `append_columns(cols)` | Misc | Join additional structured columns |
212
+
213
+ ## Examples
214
+
215
+ See [examples/full_pipeline.py](examples/full_pipeline.py) for a complete
216
+ end-to-end script demonstrating all five pillars.
217
+
218
+ ## Dependencies
219
+
220
+ - [numpy](https://numpy.org/) (>=1.24) — required
221
+ - [vcti-nputils](https://pypi.org/project/vcti-nputils/) (>=1.0.0) — required
222
+ - [pandas](https://pandas.pydata.org/) (>=2.0) — optional, for `to_dataframe()` and Jupyter display
@@ -0,0 +1,13 @@
1
+ vcti/arraydisplay/__init__.py,sha256=w9Mh7ILQwDwXlH7TJrzsuWpktxfL3rU2_Jvc6-nYBAM,528
2
+ vcti/arraydisplay/_exclusion.py,sha256=VC6kzVEQivmI8c-EcT7thX4OvcCzzSXuOuF-lxFXDes,1930
3
+ vcti/arraydisplay/_flattening.py,sha256=IwqNxCKEO8Z0FyrZT-VVS_KbDJewzkq-hhlyDt7z5f0,1555
4
+ vcti/arraydisplay/_formatting.py,sha256=qJp9atqMHNCcWEtue0Tt-873V-mttlOWdrL-xWaXlSA,1191
5
+ vcti/arraydisplay/array_display.py,sha256=XFqtpyV84JNhEEjkarup5pg-Zi4NV5XbquQvVOB11Ks,23977
6
+ vcti/arraydisplay/array_display.pyi,sha256=fa_NkQ8chC0s_Uii2oYN6-V9lK4FI3a5-1raZNp-4Ss,2783
7
+ vcti/arraydisplay/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ vcti_array_display-1.1.0.dist-info/licenses/LICENSE,sha256=gqRj-E4YRsT7mZ52W76LG6aTTFv6iEOK9QR_fV5EdrI,369
9
+ vcti_array_display-1.1.0.dist-info/METADATA,sha256=pQ-dNbBYaObnx41BXMYzc2u-x5nACi_HVYW18bV6P9Y,7707
10
+ vcti_array_display-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ vcti_array_display-1.1.0.dist-info/top_level.txt,sha256=Jl6AIAI3Xhru_BFQAhD_13VeXLmZQd9BqBNUaAKNgKs,5
12
+ vcti_array_display-1.1.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
13
+ vcti_array_display-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
2
+ All Rights Reserved.
3
+
4
+ This software is proprietary and confidential. Unauthorized copying,
5
+ distribution, or use of this software, via any medium, is strictly
6
+ prohibited. Access is granted only to authorized VCollab developers
7
+ and individuals explicitly authorized by Visual Collaboration
8
+ Technologies Inc.
@@ -0,0 +1 @@
1
+ vcti