vcti-derived 1.0.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,34 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """vcti.derived — An extensible, catalog-driven library for computing
4
+ derived fields from multi-component engineering data arrays.
5
+ """
6
+
7
+ from importlib.metadata import version
8
+
9
+ from . import dof6, ranges, scalar, symtensor, vector
10
+ from .catalog import CATALOG, Catalog, Context, DerivedField, OutputKind, Parameter
11
+ from .families import CANONICAL_COMPONENTS, DataFamily
12
+ from .order import ComponentOrder, OrderLike, SolverOrder
13
+
14
+ __version__ = version("vcti-derived")
15
+
16
+ __all__ = [
17
+ "CANONICAL_COMPONENTS",
18
+ "CATALOG",
19
+ "Catalog",
20
+ "ComponentOrder",
21
+ "Context",
22
+ "DataFamily",
23
+ "DerivedField",
24
+ "OrderLike",
25
+ "OutputKind",
26
+ "Parameter",
27
+ "SolverOrder",
28
+ "__version__",
29
+ "dof6",
30
+ "ranges",
31
+ "scalar",
32
+ "symtensor",
33
+ "vector",
34
+ ]
@@ -0,0 +1,649 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """The catalog: which derived fields exist, per data family.
4
+
5
+ Each :class:`DerivedField` entry carries a stable id (append-only once
6
+ shipped — downstream registries build dispatch values from them), display
7
+ metadata, an output kind, a declared parameter spec, and the bound kernel.
8
+ The module-level :data:`CATALOG` is the populated registry; consumers
9
+ enumerate it (``by_family``, ``default``) and compute through entries
10
+ (:meth:`DerivedField.compute`).
11
+
12
+ Entries never contain math: every kernel lives in a family module, and
13
+ conceptual compositions are fused into kernel parameters there (see
14
+ ``docs/design.md``, "Parameterized kernels").
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable, Iterator
20
+ from dataclasses import dataclass
21
+ from enum import StrEnum
22
+ from functools import partial
23
+ from typing import Any, Final
24
+
25
+ from numpy.typing import NDArray
26
+
27
+ from . import dof6, scalar, symtensor, vector
28
+ from .families import CANONICAL_COMPONENTS, DataFamily
29
+ from .order import OrderLike
30
+
31
+ type Array = NDArray[Any]
32
+ type Kernel = Callable[..., Array]
33
+
34
+
35
+ class OutputKind(StrEnum):
36
+ """The shape of a derived field's output."""
37
+
38
+ SCALAR = "scalar" # (N,)
39
+ VECTOR = "vector" # (N, 2) or (N, 3)
40
+ MATRIX = "matrix" # (N, k, k)
41
+
42
+
43
+ class Context(StrEnum):
44
+ """The physical vocabulary an entry belongs to, for consumer filtering."""
45
+
46
+ GENERAL = "general"
47
+ STRESS = "stress"
48
+ STRAIN = "strain"
49
+ ENGINEERING_STRAIN = "engineering_strain"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Parameter:
54
+ """A declared kernel parameter: name, kind, and default.
55
+
56
+ ``default=None`` marks the parameter as required (e.g. the plane
57
+ normal of a traction entry).
58
+ """
59
+
60
+ name: str
61
+ kind: str # "float" | "vector"
62
+ default: float | tuple[float, ...] | None = None
63
+ description: str = ""
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class DerivedField:
68
+ """One derived field: a named computation on one data family."""
69
+
70
+ id: str
71
+ family: DataFamily
72
+ name: str
73
+ display_name: str
74
+ description: str
75
+ output_kind: OutputKind
76
+ kernel: Kernel
77
+ parameters: tuple[Parameter, ...] = ()
78
+ contexts: tuple[Context, ...] = (Context.GENERAL,)
79
+
80
+ def compute(self, a: Array, *, order: OrderLike = None, **params: object) -> Array:
81
+ """Run the kernel on ``a`` with validated parameters.
82
+
83
+ ``order`` maps the caller's component layout onto canonical order
84
+ (scalar-family entries reject it — scalars have no components).
85
+ """
86
+ declared = {p.name: p for p in self.parameters}
87
+ unknown = set(params) - set(declared)
88
+ if unknown:
89
+ raise TypeError(
90
+ f"{self.id} got unknown parameter(s) {sorted(unknown)}; "
91
+ f"declared: {sorted(declared)}"
92
+ )
93
+ merged: dict[str, object] = {}
94
+ for name, spec in declared.items():
95
+ if name in params:
96
+ merged[name] = params[name]
97
+ elif spec.default is not None:
98
+ merged[name] = spec.default
99
+ else:
100
+ raise TypeError(f"{self.id} requires parameter {name!r}")
101
+ if self.family is DataFamily.SCALAR:
102
+ if order is not None:
103
+ raise TypeError("scalar fields have no component order")
104
+ return self.kernel(a, **merged)
105
+ return self.kernel(a, order=order, **merged)
106
+
107
+
108
+ class Catalog:
109
+ """The registry of derived fields, queryable by family and context."""
110
+
111
+ def __init__(self) -> None:
112
+ self._entries: dict[str, DerivedField] = {}
113
+ self._defaults: dict[DataFamily, str] = {}
114
+
115
+ def register(self, entry: DerivedField, *, default: bool = False) -> None:
116
+ """Add an entry; ids are unique and, once shipped, append-only."""
117
+ if entry.id in self._entries:
118
+ raise ValueError(f"duplicate derived-field id {entry.id!r}")
119
+ self._entries[entry.id] = entry
120
+ if default:
121
+ self._defaults[entry.family] = entry.id
122
+
123
+ def get(self, field_id: str) -> DerivedField:
124
+ """The entry with this id."""
125
+ try:
126
+ return self._entries[field_id]
127
+ except KeyError:
128
+ raise KeyError(f"unknown derived field {field_id!r}") from None
129
+
130
+ def ids(self) -> tuple[str, ...]:
131
+ """All entry ids, in registration order."""
132
+ return tuple(self._entries)
133
+
134
+ def by_family(
135
+ self, family: DataFamily, *, context: Context | None = None
136
+ ) -> tuple[DerivedField, ...]:
137
+ """The family's entries, optionally filtered to one physical context.
138
+
139
+ With a ``context``, entries tagged :attr:`Context.GENERAL` are
140
+ always included alongside the context-specific ones.
141
+ """
142
+ entries = (e for e in self._entries.values() if e.family is family)
143
+ if context is None:
144
+ return tuple(entries)
145
+ return tuple(e for e in entries if context in e.contexts or Context.GENERAL in e.contexts)
146
+
147
+ def default(self, family: DataFamily) -> DerivedField:
148
+ """The family's default entry (e.g. von Mises for symtensor3d)."""
149
+ try:
150
+ return self._entries[self._defaults[family]]
151
+ except KeyError:
152
+ raise KeyError(f"no default derived field for {family.value}") from None
153
+
154
+ def __contains__(self, field_id: str) -> bool:
155
+ return field_id in self._entries
156
+
157
+ def __iter__(self) -> Iterator[DerivedField]:
158
+ return iter(self._entries.values())
159
+
160
+ def __len__(self) -> int:
161
+ return len(self._entries)
162
+
163
+
164
+ _NU: Final = Parameter("nu", "float", 0.5, "Effective Poisson's ratio (0.5 = fully plastic).")
165
+ _NORMAL3: Final = Parameter("normal", "vector", None, "Unit plane normal, (3,) or (N, 3).")
166
+
167
+ _COMPONENT_DISPLAY: Final[dict[str, str]] = {
168
+ "tx": "Translational X",
169
+ "ty": "Translational Y",
170
+ "tz": "Translational Z",
171
+ "rx": "Rotational X",
172
+ "ry": "Rotational Y",
173
+ "rz": "Rotational Z",
174
+ }
175
+
176
+
177
+ def _principal(
178
+ a: Array,
179
+ *,
180
+ index: int,
181
+ shear_factor: float = 1.0,
182
+ order: OrderLike = None,
183
+ ) -> Array:
184
+ return symtensor.principals(a, shear_factor=shear_factor, order=order)[:, index]
185
+
186
+
187
+ def _principal_direction(a: Array, *, index: int, order: OrderLike = None) -> Array:
188
+ return symtensor.principal_directions(a, order=order)[:, :, index]
189
+
190
+
191
+ def _deviatoric_principal(a: Array, *, index: int, order: OrderLike = None) -> Array:
192
+ return symtensor.deviatoric_principals(a, order=order)[:, index]
193
+
194
+
195
+ def _register_components(
196
+ catalog: Catalog,
197
+ family: DataFamily,
198
+ kernel: Kernel,
199
+ *,
200
+ contexts: tuple[Context, ...] = (Context.GENERAL,),
201
+ ) -> None:
202
+ for label in CANONICAL_COMPONENTS[family]:
203
+ catalog.register(
204
+ DerivedField(
205
+ id=f"{family.value}.{label}",
206
+ family=family,
207
+ name=label,
208
+ display_name=_COMPONENT_DISPLAY.get(label, label.upper()),
209
+ description=f"The {label.upper()} component.",
210
+ output_kind=OutputKind.SCALAR,
211
+ kernel=partial(kernel, comp=label),
212
+ contexts=contexts,
213
+ )
214
+ )
215
+
216
+
217
+ def _register_scalar(catalog: Catalog) -> None:
218
+ family = DataFamily.SCALAR
219
+
220
+ def entry(
221
+ name: str,
222
+ display: str,
223
+ description: str,
224
+ kernel: Kernel,
225
+ parameters: tuple[Parameter, ...] = (),
226
+ ) -> DerivedField:
227
+ return DerivedField(
228
+ id=f"{family.value}.{name}",
229
+ family=family,
230
+ name=name,
231
+ display_name=display,
232
+ description=description,
233
+ output_kind=OutputKind.SCALAR,
234
+ kernel=kernel,
235
+ parameters=parameters,
236
+ )
237
+
238
+ catalog.register(
239
+ entry("identity", "Value", "The values unchanged.", scalar.identity),
240
+ default=True,
241
+ )
242
+ catalog.register(
243
+ entry("absolute", "Absolute Value", "Element-wise absolute value.", scalar.absolute)
244
+ )
245
+ catalog.register(
246
+ entry(
247
+ "affine",
248
+ "Affine Conversion",
249
+ "scale · x + offset — the unit-conversion primitive.",
250
+ scalar.affine,
251
+ (
252
+ Parameter("scale", "float", 1.0, "Multiplier."),
253
+ Parameter("offset", "float", 0.0, "Additive offset."),
254
+ ),
255
+ )
256
+ )
257
+ for name in scalar.TEMPERATURE_CONVERSIONS:
258
+ source, _, target = name.partition("_to_")
259
+ catalog.register(
260
+ entry(
261
+ name,
262
+ f"{source.capitalize()} → {target.capitalize()}",
263
+ f"Temperature conversion, {source} to {target}.",
264
+ partial(scalar.convert, conversion=name),
265
+ )
266
+ )
267
+
268
+
269
+ def _register_vectors(catalog: Catalog) -> None:
270
+ for family in (DataFamily.VECTOR2, DataFamily.VECTOR3):
271
+ _register_components(catalog, family, vector.component)
272
+ catalog.register(
273
+ DerivedField(
274
+ id=f"{family.value}.magnitude",
275
+ family=family,
276
+ name="magnitude",
277
+ display_name="Resultant",
278
+ description="Euclidean magnitude of the vector.",
279
+ output_kind=OutputKind.SCALAR,
280
+ kernel=vector.magnitude,
281
+ ),
282
+ default=True,
283
+ )
284
+
285
+
286
+ def _register_dof6(catalog: Catalog) -> None:
287
+ for family in (DataFamily.DOF6_2D, DataFamily.DOF6_3D):
288
+ _register_components(catalog, family, dof6.component)
289
+ k = len(CANONICAL_COMPONENTS[family]) // 2
290
+
291
+ def entry(
292
+ name: str,
293
+ display: str,
294
+ description: str,
295
+ kind: OutputKind,
296
+ kernel: Kernel,
297
+ *,
298
+ fam: DataFamily = family,
299
+ ) -> DerivedField:
300
+ return DerivedField(
301
+ id=f"{fam.value}.{name}",
302
+ family=fam,
303
+ name=name,
304
+ display_name=display,
305
+ description=description,
306
+ output_kind=kind,
307
+ kernel=kernel,
308
+ )
309
+
310
+ catalog.register(
311
+ entry(
312
+ "translation",
313
+ "Translation Vector",
314
+ f"The translational part, (N, {k}).",
315
+ OutputKind.VECTOR,
316
+ dof6.translation,
317
+ )
318
+ )
319
+ catalog.register(
320
+ entry(
321
+ "rotation",
322
+ "Rotation Vector",
323
+ f"The rotational part, (N, {k}).",
324
+ OutputKind.VECTOR,
325
+ dof6.rotation,
326
+ )
327
+ )
328
+ catalog.register(
329
+ entry(
330
+ "translation_magnitude",
331
+ "Translational Magnitude",
332
+ "Magnitude of the translational part.",
333
+ OutputKind.SCALAR,
334
+ dof6.translation_magnitude,
335
+ ),
336
+ default=True,
337
+ )
338
+ catalog.register(
339
+ entry(
340
+ "rotation_magnitude",
341
+ "Rotational Magnitude",
342
+ "Magnitude of the rotational part.",
343
+ OutputKind.SCALAR,
344
+ dof6.rotation_magnitude,
345
+ )
346
+ )
347
+
348
+
349
+ def _tensor_entry(
350
+ family: DataFamily,
351
+ name: str,
352
+ display: str,
353
+ description: str,
354
+ kernel: Kernel,
355
+ *,
356
+ kind: OutputKind = OutputKind.SCALAR,
357
+ parameters: tuple[Parameter, ...] = (),
358
+ contexts: tuple[Context, ...] = (Context.GENERAL,),
359
+ ) -> DerivedField:
360
+ return DerivedField(
361
+ id=f"{family.value}.{name}",
362
+ family=family,
363
+ name=name,
364
+ display_name=display,
365
+ description=description,
366
+ output_kind=kind,
367
+ kernel=kernel,
368
+ parameters=parameters,
369
+ contexts=contexts,
370
+ )
371
+
372
+
373
+ def _register_symtensor2d(catalog: Catalog) -> None:
374
+ family = DataFamily.SYMTENSOR2D
375
+ _register_components(catalog, family, symtensor.component)
376
+ entries = [
377
+ _tensor_entry(family, "mean", "Mean", "In-plane mean: (xx + yy) / 2.", symtensor.mean),
378
+ _tensor_entry(
379
+ family,
380
+ "von_mises",
381
+ "Von Mises",
382
+ "Plane-stress von Mises value.",
383
+ symtensor.von_mises,
384
+ contexts=(Context.STRESS,),
385
+ ),
386
+ _tensor_entry(
387
+ family,
388
+ "principals",
389
+ "Principal Values",
390
+ "Both in-plane principal values, ascending, (N, 2).",
391
+ symtensor.principals,
392
+ kind=OutputKind.VECTOR,
393
+ ),
394
+ _tensor_entry(
395
+ family,
396
+ "min_principal",
397
+ "Minimum Principal Value",
398
+ "The smaller principal value.",
399
+ partial(_principal, index=0),
400
+ ),
401
+ _tensor_entry(
402
+ family,
403
+ "max_principal",
404
+ "Maximum Principal Value",
405
+ "The larger principal value.",
406
+ partial(_principal, index=-1),
407
+ ),
408
+ _tensor_entry(
409
+ family,
410
+ "abs_max_principal",
411
+ "Absolute Maximum Principal Value",
412
+ "Signed in-plane principal value of largest magnitude.",
413
+ symtensor.abs_max_principal,
414
+ ),
415
+ _tensor_entry(
416
+ family,
417
+ "principal_directions",
418
+ "Principal Directions",
419
+ "Unit eigenvectors, (N, 2, 2); [:, :, i] pairs with principal i.",
420
+ symtensor.principal_directions,
421
+ kind=OutputKind.MATRIX,
422
+ ),
423
+ _tensor_entry(
424
+ family,
425
+ "max_shear",
426
+ "Maximum Shear",
427
+ "In-plane maximum shear: (tmax − tmin) / 2 (no out-of-plane term).",
428
+ symtensor.max_shear,
429
+ contexts=(Context.STRESS,),
430
+ ),
431
+ _tensor_entry(
432
+ family,
433
+ "max_shear_angle",
434
+ "Maximum Shear Angle",
435
+ "Angle of the max-shear direction with the x-axis, degrees.",
436
+ symtensor.max_shear_angle,
437
+ ),
438
+ ]
439
+ for e in entries:
440
+ catalog.register(e, default=(e.name == "von_mises"))
441
+
442
+
443
+ def _register_symtensor3d(catalog: Catalog) -> None:
444
+ family = DataFamily.SYMTENSOR3D
445
+ _register_components(catalog, family, symtensor.component)
446
+ principal_names = ("min", "mid", "max")
447
+ entries = [
448
+ _tensor_entry(
449
+ family,
450
+ "mean",
451
+ "Mean (Hydrostatic)",
452
+ "Hydrostatic value: (xx + yy + zz) / 3.",
453
+ symtensor.mean,
454
+ ),
455
+ _tensor_entry(
456
+ family,
457
+ "von_mises",
458
+ "Von Mises Stress",
459
+ "The von Mises equivalent value.",
460
+ symtensor.von_mises,
461
+ contexts=(Context.STRESS,),
462
+ ),
463
+ _tensor_entry(
464
+ family,
465
+ "equivalent_strain",
466
+ "Von Mises Strain",
467
+ "1/(1+ν) of the von Mises value; tensorial shear storage.",
468
+ symtensor.equivalent_strain,
469
+ parameters=(_NU,),
470
+ contexts=(Context.STRAIN,),
471
+ ),
472
+ _tensor_entry(
473
+ family,
474
+ "equivalent_strain_engineering",
475
+ "Von Mises Strain (Engineering)",
476
+ "1/(1+ν) of the von Mises value; engineering-shear storage.",
477
+ partial(symtensor.equivalent_strain, shear_factor=0.5),
478
+ parameters=(_NU,),
479
+ contexts=(Context.ENGINEERING_STRAIN,),
480
+ ),
481
+ _tensor_entry(
482
+ family,
483
+ "octahedral",
484
+ "Octahedral Shear Stress",
485
+ "τ_oct = (√2⁄3) · σ_vm.",
486
+ symtensor.octahedral,
487
+ contexts=(Context.STRESS,),
488
+ ),
489
+ _tensor_entry(
490
+ family,
491
+ "principals",
492
+ "Principal Values",
493
+ "All three principal values, ascending, (N, 3).",
494
+ symtensor.principals,
495
+ kind=OutputKind.VECTOR,
496
+ ),
497
+ _tensor_entry(
498
+ family,
499
+ "principal_directions",
500
+ "Principal Directions",
501
+ "Unit eigenvectors, (N, 3, 3); [:, :, i] pairs with principal i.",
502
+ symtensor.principal_directions,
503
+ kind=OutputKind.MATRIX,
504
+ ),
505
+ _tensor_entry(
506
+ family,
507
+ "max_shear",
508
+ "Maximum Shear",
509
+ "(tmax − tmin) / 2.",
510
+ symtensor.max_shear,
511
+ contexts=(Context.STRESS,),
512
+ ),
513
+ _tensor_entry(
514
+ family,
515
+ "equal_direct",
516
+ "Equal Direct at Maximum Shear",
517
+ "(tmax + tmin) / 2.",
518
+ symtensor.equal_direct,
519
+ contexts=(Context.STRESS,),
520
+ ),
521
+ _tensor_entry(
522
+ family,
523
+ "intensity",
524
+ "Intensity",
525
+ "tmax − tmin (twice the maximum shear).",
526
+ symtensor.intensity,
527
+ ),
528
+ _tensor_entry(
529
+ family,
530
+ "abs_max_principal",
531
+ "Absolute Maximum Principal Value",
532
+ "Signed principal value of largest magnitude.",
533
+ symtensor.abs_max_principal,
534
+ ),
535
+ _tensor_entry(
536
+ family,
537
+ "abs_max_principal_engineering",
538
+ "Absolute Maximum Principal Strain (Engineering)",
539
+ "Signed principal of largest magnitude, engineering-shear storage.",
540
+ partial(symtensor.abs_max_principal, shear_factor=0.5),
541
+ contexts=(Context.ENGINEERING_STRAIN,),
542
+ ),
543
+ _tensor_entry(
544
+ family,
545
+ "determinant",
546
+ "Determinant",
547
+ "The third invariant.",
548
+ symtensor.determinant,
549
+ ),
550
+ _tensor_entry(
551
+ family,
552
+ "traction",
553
+ "Traction Vector",
554
+ "t = σ·n on the plane with normal n, (N, 3).",
555
+ symtensor.traction,
556
+ kind=OutputKind.VECTOR,
557
+ parameters=(_NORMAL3,),
558
+ contexts=(Context.STRESS,),
559
+ ),
560
+ _tensor_entry(
561
+ family,
562
+ "traction_normal",
563
+ "Normal Stress Vector",
564
+ "Normal component of the traction: (t·n)·n, (N, 3).",
565
+ symtensor.traction_normal,
566
+ kind=OutputKind.VECTOR,
567
+ parameters=(_NORMAL3,),
568
+ contexts=(Context.STRESS,),
569
+ ),
570
+ _tensor_entry(
571
+ family,
572
+ "traction_shear",
573
+ "Shear Stress Vector",
574
+ "Shear component of the traction: t − (t·n)·n, (N, 3).",
575
+ symtensor.traction_shear,
576
+ kind=OutputKind.VECTOR,
577
+ parameters=(_NORMAL3,),
578
+ contexts=(Context.STRESS,),
579
+ ),
580
+ ]
581
+ for i, which in enumerate(principal_names):
582
+ entries.append(
583
+ _tensor_entry(
584
+ family,
585
+ f"{which}_principal",
586
+ f"{which.capitalize()}imum Principal Value"
587
+ if which != "mid"
588
+ else "Middle Principal Value",
589
+ f"The {which} principal value.",
590
+ partial(_principal, index=i),
591
+ )
592
+ )
593
+ entries.append(
594
+ _tensor_entry(
595
+ family,
596
+ f"{which}_principal_engineering",
597
+ (
598
+ f"{which.capitalize()}imum Principal Strain (Engineering)"
599
+ if which != "mid"
600
+ else "Middle Principal Strain (Engineering)"
601
+ ),
602
+ f"The {which} principal value with engineering-shear storage.",
603
+ partial(_principal, index=i, shear_factor=0.5),
604
+ contexts=(Context.ENGINEERING_STRAIN,),
605
+ )
606
+ )
607
+ entries.append(
608
+ _tensor_entry(
609
+ family,
610
+ f"{which}_principal_direction",
611
+ (
612
+ f"{which.capitalize()}imum Principal Direction"
613
+ if which != "mid"
614
+ else "Middle Principal Direction"
615
+ ),
616
+ f"Unit direction of the {which} principal value, (N, 3).",
617
+ partial(_principal_direction, index=i),
618
+ kind=OutputKind.VECTOR,
619
+ )
620
+ )
621
+ entries.append(
622
+ _tensor_entry(
623
+ family,
624
+ f"deviatoric_{which}_principal",
625
+ (
626
+ f"Deviatoric {which.capitalize()}imum Principal Value"
627
+ if which != "mid"
628
+ else "Deviatoric Middle Principal Value"
629
+ ),
630
+ f"The {which} principal value of the deviator.",
631
+ partial(_deviatoric_principal, index=i),
632
+ )
633
+ )
634
+ for e in entries:
635
+ catalog.register(e, default=(e.name == "von_mises"))
636
+
637
+
638
+ def _build_catalog() -> Catalog:
639
+ catalog = Catalog()
640
+ _register_scalar(catalog)
641
+ _register_vectors(catalog)
642
+ _register_dof6(catalog)
643
+ _register_symtensor2d(catalog)
644
+ _register_symtensor3d(catalog)
645
+ return catalog
646
+
647
+
648
+ CATALOG: Final[Catalog] = _build_catalog()
649
+ """The populated derived-field registry."""