morphis 0.10.0__tar.gz → 0.12.0__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 (110) hide show
  1. {morphis-0.10.0 → morphis-0.12.0}/PKG-INFO +11 -1
  2. {morphis-0.10.0 → morphis-0.12.0}/pyproject.toml +15 -1
  3. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/contraction.py +24 -17
  4. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/solvers.py +6 -7
  5. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/tests/test_contraction.py +84 -0
  6. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/__init__.py +5 -1
  7. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/base.py +28 -0
  8. morphis-0.12.0/src/morphis/elements/field.py +102 -0
  9. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/multivector.py +45 -0
  10. morphis-0.12.0/src/morphis/elements/surface.py +367 -0
  11. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/vector.py +37 -3
  12. morphis-0.12.0/src/morphis/examples/astronaut_animated.py +113 -0
  13. morphis-0.12.0/src/morphis/examples/astronaut_view.py +25 -0
  14. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/operators.py +56 -55
  15. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/rotations_3d.py +13 -27
  16. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/rotations_4d.py +16 -30
  17. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/operator.py +10 -4
  18. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/transforms/__init__.py +2 -0
  19. morphis-0.12.0/src/morphis/transforms/rotations.py +348 -0
  20. morphis-0.12.0/src/morphis/transforms/tests/test_rotations.py +212 -0
  21. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/utils/docgen.py +1 -0
  22. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/__init__.py +30 -4
  23. morphis-0.12.0/src/morphis/visuals/backends/__init__.py +39 -0
  24. morphis-0.12.0/src/morphis/visuals/backends/protocol.py +450 -0
  25. morphis-0.12.0/src/morphis/visuals/backends/pyvista.py +861 -0
  26. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/canvas.py +99 -12
  27. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/drawing/vectors.py +54 -32
  28. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/loop.py +94 -26
  29. morphis-0.12.0/src/morphis/visuals/model.py +410 -0
  30. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/renderer.py +59 -8
  31. morphis-0.12.0/src/morphis/visuals/scene.py +644 -0
  32. morphis-0.12.0/src/morphis/visuals/tests/__init__.py +0 -0
  33. morphis-0.12.0/src/morphis/visuals/tests/test_model.py +434 -0
  34. morphis-0.12.0/src/morphis/visuals/text.py +133 -0
  35. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/theme.py +112 -0
  36. morphis-0.10.0/src/morphis/transforms/rotations.py +0 -142
  37. {morphis-0.10.0 → morphis-0.12.0}/README.md +0 -0
  38. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/__init__.py +0 -0
  39. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/_legacy/__init__.py +0 -0
  40. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/_legacy/coordinates.py +0 -0
  41. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/_legacy/rotations.py +0 -0
  42. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/_legacy/smoothing.py +0 -0
  43. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/_legacy/vectors.py +0 -0
  44. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/__init__.py +0 -0
  45. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/patterns.py +0 -0
  46. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/specs.py +0 -0
  47. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/tests/__init__.py +0 -0
  48. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/tests/test_patterns.py +0 -0
  49. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/tests/test_solvers.py +0 -0
  50. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/algebra/tests/test_specs.py +0 -0
  51. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/config.py +0 -0
  52. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/frame.py +0 -0
  53. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/lot_indexed.py +0 -0
  54. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/metric.py +0 -0
  55. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/operator.py +0 -0
  56. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/protocols.py +0 -0
  57. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tensor.py +0 -0
  58. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tests/__init__.py +0 -0
  59. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tests/test_complex_blades.py +0 -0
  60. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tests/test_maxwell_features.py +0 -0
  61. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tests/test_model.py +0 -0
  62. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tests/test_operator.py +0 -0
  63. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tests/test_operators.py +0 -0
  64. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/elements/tests/test_tensor.py +0 -0
  65. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/__init__.py +0 -0
  66. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/clifford.py +0 -0
  67. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/duality.py +0 -0
  68. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/exterior.py +0 -0
  69. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/phasors.py +0 -0
  70. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/examples/transforms_pga.py +0 -0
  71. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/manifold/__init__.py +0 -0
  72. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/__init__.py +0 -0
  73. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/_helpers.py +0 -0
  74. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/duality.py +0 -0
  75. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/exponential.py +0 -0
  76. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/factorization.py +0 -0
  77. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/matrix_rep.py +0 -0
  78. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/norms.py +0 -0
  79. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/outermorphism.py +0 -0
  80. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/products.py +0 -0
  81. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/projections.py +0 -0
  82. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/spectral.py +0 -0
  83. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/structure.py +0 -0
  84. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/subspaces.py +0 -0
  85. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/__init__.py +0 -0
  86. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_complex_operations.py +0 -0
  87. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_duality.py +0 -0
  88. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_exponential.py +0 -0
  89. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_matrix_rep.py +0 -0
  90. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_norms.py +0 -0
  91. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_operations.py +0 -0
  92. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_outermorphism.py +0 -0
  93. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_products.py +0 -0
  94. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_spectral.py +0 -0
  95. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/operations/tests/test_structure.py +0 -0
  96. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/topology/__init__.py +0 -0
  97. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/transforms/actions.py +0 -0
  98. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/transforms/projective.py +0 -0
  99. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/transforms/tests/__init__.py +0 -0
  100. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/transforms/tests/test_projective.py +0 -0
  101. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/utils/__init__.py +0 -0
  102. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/utils/easing.py +0 -0
  103. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/utils/exceptions.py +0 -0
  104. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/utils/observer.py +0 -0
  105. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/utils/pretty.py +0 -0
  106. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/contexts.py +0 -0
  107. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/drawing/__init__.py +0 -0
  108. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/effects.py +0 -0
  109. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/operations.py +0 -0
  110. {morphis-0.10.0 → morphis-0.12.0}/src/morphis/visuals/projection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphis
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Summary: A unified mathematical framework for geometric computation
5
5
  Keywords: geometric-algebra,mathematics,visualization,multivector,pga
6
6
  Author: ctl-alt-leist
@@ -12,14 +12,17 @@ Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Topic :: Scientific/Engineering :: Mathematics
14
14
  Classifier: Topic :: Scientific/Engineering :: Visualization
15
+ Requires-Dist: dracopy>=1.4.0
15
16
  Requires-Dist: imageio>=2.37.2
16
17
  Requires-Dist: imageio-ffmpeg>=0.6.0
17
18
  Requires-Dist: matplotlib>=3.10.8
18
19
  Requires-Dist: numpy>=2.3.5
19
20
  Requires-Dist: pydantic>=2.12.5
21
+ Requires-Dist: pygltflib>=1.16.5
20
22
  Requires-Dist: pyvista>=0.46.4
21
23
  Requires-Dist: scipy>=1.16.3
22
24
  Requires-Dist: tomli>=2.3.0
25
+ Requires-Dist: trimesh>=4.11.1
23
26
  Requires-Dist: build ; extra == 'dev'
24
27
  Requires-Dist: flake8 ; extra == 'dev'
25
28
  Requires-Dist: ipython ; extra == 'dev'
@@ -28,11 +31,18 @@ Requires-Dist: pre-commit ; extra == 'dev'
28
31
  Requires-Dist: pytest ; extra == 'dev'
29
32
  Requires-Dist: python-lsp-server ; extra == 'dev'
30
33
  Requires-Dist: ruff ; extra == 'dev'
34
+ Requires-Dist: pyvista>=0.46.4 ; extra == 'visuals'
35
+ Requires-Dist: imageio>=2.37.2 ; extra == 'visuals'
36
+ Requires-Dist: imageio-ffmpeg>=0.6.0 ; extra == 'visuals'
37
+ Requires-Dist: trimesh>=4.11.1 ; extra == 'visuals'
38
+ Requires-Dist: pygltflib>=1.16.5 ; extra == 'visuals'
39
+ Requires-Dist: dracopy>=1.4.0 ; extra == 'visuals'
31
40
  Requires-Python: >=3.12
32
41
  Project-URL: Homepage, https://github.com/ctl-alt-leist/morphis
33
42
  Project-URL: Repository, https://github.com/ctl-alt-leist/morphis
34
43
  Project-URL: Issues, https://github.com/ctl-alt-leist/morphis/issues
35
44
  Provides-Extra: dev
45
+ Provides-Extra: visuals
36
46
  Description-Content-Type: text/markdown
37
47
 
38
48
  # Morphis
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "morphis"
3
- version = "0.10.0"
3
+ version = "0.12.0"
4
4
  description = "A unified mathematical framework for geometric computation"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -18,17 +18,31 @@ classifiers = [
18
18
  "Topic :: Scientific/Engineering :: Visualization",
19
19
  ]
20
20
  dependencies = [
21
+ "DracoPy>=1.4.0",
21
22
  "imageio>=2.37.2",
22
23
  "imageio-ffmpeg>=0.6.0",
23
24
  "matplotlib>=3.10.8",
24
25
  "numpy>=2.3.5",
25
26
  "pydantic>=2.12.5",
27
+ "pygltflib>=1.16.5",
26
28
  "pyvista>=0.46.4",
27
29
  "scipy>=1.16.3",
28
30
  "tomli>=2.3.0",
31
+ "trimesh>=4.11.1",
29
32
  ]
30
33
 
31
34
  [project.optional-dependencies]
35
+ visuals = [
36
+ # These are currently in main dependencies but documented here
37
+ # for explicit dependency declaration. In a future major version,
38
+ # these may be moved out of main dependencies.
39
+ "pyvista>=0.46.4",
40
+ "imageio>=2.37.2",
41
+ "imageio-ffmpeg>=0.6.0",
42
+ "trimesh>=4.11.1",
43
+ "pygltflib>=1.16.5",
44
+ "DracoPy>=1.4.0",
45
+ ]
32
46
  dev = [
33
47
  "build",
34
48
  "flake8",
@@ -37,6 +37,8 @@ class IndexedTensor:
37
37
  Attributes:
38
38
  tensor: The underlying Vector or Operator (reference, not copy)
39
39
  indices: String of index labels (e.g., "mnab")
40
+ output_geo_indices: Indices that represent output geometric dimensions.
41
+ These determine the result grade when present in contraction output.
40
42
 
41
43
  Examples:
42
44
  >>> G = Operator(...) # lot=(M, N), grade=2 output
@@ -44,18 +46,21 @@ class IndexedTensor:
44
46
  >>> b = G["mnab"] * q["n"] # contracts on 'n', result has indices "mab"
45
47
  """
46
48
 
47
- __slots__ = ("tensor", "indices")
49
+ __slots__ = ("tensor", "indices", "output_geo_indices")
48
50
 
49
- def __init__(self, tensor: "Vector | Operator", indices: str):
51
+ def __init__(self, tensor: "Vector | Operator", indices: str, output_geo_indices: str = ""):
50
52
  """
51
53
  Create an indexed tensor wrapper.
52
54
 
53
55
  Args:
54
56
  tensor: The underlying Vector or Operator
55
57
  indices: String of index labels, one per axis of tensor.data
58
+ output_geo_indices: Subset of indices representing output geometric
59
+ dimensions. If empty, will be inferred during contraction (legacy).
56
60
  """
57
61
  self.tensor = tensor
58
62
  self.indices = indices
63
+ self.output_geo_indices = output_geo_indices
59
64
 
60
65
  # Validate index count matches tensor dimensions
61
66
  expected_ndim = tensor.data.ndim
@@ -78,9 +83,14 @@ class IndexedTensor:
78
83
 
79
84
  if isinstance(other, LotIndexed):
80
85
  # Convert LotIndexed to IndexedTensor by adding geo indices
86
+ # All of a Vector's geometric indices are "output geometric"
81
87
  n_geo = other.vector.grade
82
88
  geo_labels = "".join(chr(ord("A") + i) for i in range(n_geo))
83
- other = IndexedTensor(other.vector, other.indices + geo_labels)
89
+ other = IndexedTensor(
90
+ other.vector,
91
+ other.indices + geo_labels,
92
+ output_geo_indices=geo_labels,
93
+ )
84
94
 
85
95
  if not isinstance(other, IndexedTensor):
86
96
  return NotImplemented
@@ -152,23 +162,20 @@ def _contract_indexed(*indexed_tensors: IndexedTensor) -> "Vector":
152
162
 
153
163
 
154
164
  def _infer_grade_from_indexed(indexed_tensors: tuple[IndexedTensor, ...], output_indices: str) -> int:
155
- """Infer grade for IndexedTensor contraction result."""
156
- from morphis.elements.vector import Vector
157
-
158
- # Track which indices are geometric (vs lot)
159
- geo_indices = set()
165
+ """
166
+ Infer grade for IndexedTensor contraction result.
160
167
 
168
+ The result grade equals the number of output geometric indices that appear
169
+ in the result. Each IndexedTensor carries its output_geo_indices, which
170
+ identify which indices represent geometric (grade-contributing) dimensions.
171
+ """
172
+ # Collect output geometric indices from all tensors
173
+ all_output_geo = set()
161
174
  for it in indexed_tensors:
162
- if isinstance(it.tensor, Vector):
163
- n_lot = len(it.tensor.lot)
164
- n_geo = it.tensor.grade
165
- # Geometric indices are the last 'grade' indices
166
- geo_part = it.indices[n_lot : n_lot + n_geo]
167
- geo_indices.update(geo_part)
175
+ all_output_geo.update(it.output_geo_indices)
168
176
 
169
- # Count geometric indices in output
170
- result_grade = sum(1 for idx in output_indices if idx in geo_indices)
171
- return result_grade
177
+ # Result grade = count of output geometric indices in the result
178
+ return sum(1 for idx in output_indices if idx in all_output_geo)
172
179
 
173
180
 
174
181
  # =============================================================================
@@ -8,9 +8,8 @@ structured Operators for the results.
8
8
 
9
9
  from typing import TYPE_CHECKING
10
10
 
11
- import numpy as np
12
- from numpy import prod
13
- from numpy.linalg import lstsq, svd
11
+ from numpy import diag_indices_from, prod
12
+ from numpy.linalg import lstsq, pinv, solve, svd
14
13
  from numpy.typing import NDArray
15
14
 
16
15
  from morphis.algebra.specs import VectorSpec
@@ -143,9 +142,9 @@ def structured_lstsq(
143
142
  if alpha > 0:
144
143
  # Regularized: (G^H G + alpha*I) x = G^H y
145
144
  GhG = G_matrix.conj().T @ G_matrix
146
- GhG[np.diag_indices_from(GhG)] += alpha
145
+ GhG[diag_indices_from(GhG)] += alpha
147
146
  Ghy = G_matrix.conj().T @ y_vector
148
- x_vector = np.linalg.solve(GhG, Ghy)
147
+ x_vector = solve(GhG, Ghy)
149
148
  else:
150
149
  # Standard least squares
151
150
  x_vector, _, _, _ = lstsq(G_matrix, y_vector, rcond=None)
@@ -211,9 +210,9 @@ def structured_pinv(
211
210
 
212
211
  # Compute pseudoinverse
213
212
  if r_cond is None:
214
- G_pinv = np.linalg.pinv(G_matrix)
213
+ G_pinv = pinv(G_matrix)
215
214
  else:
216
- G_pinv = np.linalg.pinv(G_matrix, rcond=r_cond)
215
+ G_pinv = pinv(G_matrix, rcond=r_cond)
217
216
 
218
217
  # Reconstruct as Operator with swapped specs
219
218
  return _from_matrix(
@@ -1,11 +1,14 @@
1
1
  """Tests for tensor contraction with both bracket and einsum-style APIs."""
2
2
 
3
+ import numpy as np
3
4
  import pytest
4
5
  from numpy import array, ones
5
6
  from numpy.testing import assert_allclose, assert_array_equal
6
7
 
7
8
  from morphis.algebra.contraction import IndexedTensor, contract
9
+ from morphis.algebra.specs import VectorSpec
8
10
  from morphis.elements import Vector, euclidean_metric
11
+ from morphis.operations.operator import Operator
9
12
 
10
13
 
11
14
  # =============================================================================
@@ -222,3 +225,84 @@ class TestSlicingStillWorks:
222
225
 
223
226
  assert isinstance(result, Vector)
224
227
  assert result.shape == (5, 3)
228
+
229
+
230
+ # =============================================================================
231
+ # Operator Indexed Contraction
232
+ # =============================================================================
233
+
234
+
235
+ class TestOperatorIndexedContraction:
236
+ """Tests for indexed contraction between Operators and Vectors."""
237
+
238
+ def test_operator_vector_contraction_preserves_output_grade(self):
239
+ """
240
+ Operator contraction should preserve output_spec.grade in result.
241
+
242
+ This is the key regression test: when an Operator with output_spec.grade=2
243
+ contracts with a Vector, the result must have grade=2, not grade=0.
244
+ """
245
+ g = euclidean_metric(3)
246
+
247
+ # Operator: maps grade-0 lot (N,) to grade-2 lot (M,)
248
+ M, N = 2, 3
249
+ O = Operator(
250
+ data=np.random.randn(M, N, 3, 3), # shape (M, N, 3, 3)
251
+ input_spec=VectorSpec(grade=0, lot=(N,), dim=3),
252
+ output_spec=VectorSpec(grade=2, lot=(M,), dim=3),
253
+ metric=g,
254
+ )
255
+
256
+ # Input: grade-0 with lot (N,)
257
+ v = Vector(np.random.randn(N), grade=0, metric=g)
258
+
259
+ # Contract on "n" (the input lot index)
260
+ result = O["mnab"] * v["n"]
261
+
262
+ # Result should have: grade=2, lot=(M,)
263
+ assert result.grade == 2, f"Expected grade=2, got grade={result.grade}"
264
+ assert result.lot == (M,), f"Expected lot=(2,), got lot={result.lot}"
265
+ assert result.shape == (M, 3, 3)
266
+
267
+ def test_operator_lot_indexed_vector_contraction(self):
268
+ """Operator contraction via LotIndexed syntax also preserves grade."""
269
+ g = euclidean_metric(3)
270
+
271
+ M, N = 4, 5
272
+ O = Operator(
273
+ data=np.ones((M, N, 3, 3)),
274
+ input_spec=VectorSpec(grade=0, lot=(N,), dim=3),
275
+ output_spec=VectorSpec(grade=2, lot=(M,), dim=3),
276
+ metric=g,
277
+ )
278
+
279
+ # Input as grade-0 vector with lot
280
+ v = Vector(np.ones(N), grade=0, metric=g)
281
+
282
+ result = O["mnab"] * v["n"]
283
+
284
+ assert result.grade == 2
285
+ assert result.lot == (M,)
286
+ # Each element should be sum over N: N * 1 = 5
287
+ assert_allclose(result.data, np.ones((M, 3, 3)) * N)
288
+
289
+ def test_operator_vector_to_vector_contraction(self):
290
+ """Operator mapping grade-1 to grade-1 preserves grade in contraction."""
291
+ g = euclidean_metric(3)
292
+
293
+ M, N = 2, 3
294
+ # grade-1 -> grade-1 operator (outermorphism)
295
+ O = Operator(
296
+ data=np.eye(3).reshape(1, 1, 3, 3) * np.ones((M, N, 1, 1)),
297
+ input_spec=VectorSpec(grade=1, lot=(N,), dim=3),
298
+ output_spec=VectorSpec(grade=1, lot=(M,), dim=3),
299
+ metric=g,
300
+ )
301
+
302
+ v = Vector(np.ones((N, 3)), grade=1, metric=g)
303
+
304
+ result = O["mnab"] * v["nb"]
305
+
306
+ assert result.grade == 1
307
+ assert result.lot == (M,)
308
+ assert result.shape == (M, 3)
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Geometric Algebra - Elements
3
3
 
4
- Core geometric algebra objects: Vectors, MultiVectors, Frames, and Metrics.
4
+ Core geometric algebra objects: Vectors, MultiVectors, Frames, Surfaces, and Metrics.
5
5
  """
6
6
 
7
7
  from morphis.elements.base import (
@@ -9,6 +9,7 @@ from morphis.elements.base import (
9
9
  Element as Element,
10
10
  GradedElement as GradedElement,
11
11
  )
12
+ from morphis.elements.field import Field
12
13
  from morphis.elements.frame import Frame
13
14
  from morphis.elements.lot_indexed import LotIndexed as LotIndexed
14
15
  from morphis.elements.metric import (
@@ -32,6 +33,7 @@ from morphis.elements.protocols import (
32
33
  Spanning as Spanning,
33
34
  Transformable as Transformable,
34
35
  )
36
+ from morphis.elements.surface import Surface
35
37
  from morphis.elements.tensor import Tensor
36
38
  from morphis.elements.vector import (
37
39
  Vector,
@@ -48,3 +50,5 @@ Vector.model_rebuild()
48
50
  MultiVector.model_rebuild()
49
51
  Frame.model_rebuild()
50
52
  Tensor.model_rebuild()
53
+ Surface.model_rebuild()
54
+ Field.model_rebuild()
@@ -30,6 +30,7 @@ from morphis.elements.metric import Metric
30
30
 
31
31
  if TYPE_CHECKING:
32
32
  from morphis.algebra.contraction import IndexedTensor
33
+ from morphis.elements.multivector import MultiVector
33
34
  from morphis.elements.vector import Vector
34
35
 
35
36
 
@@ -124,6 +125,33 @@ class Element(BaseModel):
124
125
  """Dimension of the underlying vector space."""
125
126
  return self.metric.dim
126
127
 
128
+ def apply_similarity(
129
+ self,
130
+ S: "MultiVector",
131
+ t: "Vector | None" = None,
132
+ ) -> Self:
133
+ """
134
+ Apply a similarity transformation to this element.
135
+
136
+ Computes: transform(self, S) + t
137
+
138
+ The similarity versor S (from align_vectors or a rotor) is applied via
139
+ sandwich product. The optional translation t is added after.
140
+
141
+ Args:
142
+ S: Similarity versor or rotor (MultiVector with grades {0, 2})
143
+ t: Optional translation vector (grade-1). Added after sandwich product.
144
+
145
+ Returns:
146
+ New element with the transformation applied.
147
+
148
+ Example:
149
+ S = align_vectors(u, v) # Similarity versor
150
+ t = Vector([1, 0, 0], grade=1, metric=g) # Translation
151
+ v_transformed = v.apply_similarity(S, t)
152
+ """
153
+ raise NotImplementedError("Subclasses must implement apply_similarity()")
154
+
127
155
 
128
156
  class GradedElement(Element):
129
157
  """
@@ -0,0 +1,102 @@
1
+ """
2
+ Field Element - Positions with Values (Skeleton)
3
+
4
+ Field represents a collection of positions in space, each with an associated
5
+ value (scalar, vector, frame, or multivector). This is a skeleton for future
6
+ implementation.
7
+
8
+ Typical uses:
9
+ - Scalar fields: temperature, pressure, density
10
+ - Vector fields: velocity, electric field, force
11
+ - Frame fields: stress tensors, rotation fields
12
+ - Multivector fields: electromagnetic field (F = E + IcB)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING, Self
18
+
19
+ from pydantic import ConfigDict, model_validator
20
+
21
+ from morphis.elements.base import Element
22
+ from morphis.elements.vector import Vector
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from morphis.elements.frame import Frame
27
+ from morphis.elements.metric import Metric
28
+ from morphis.elements.multivector import MultiVector
29
+
30
+
31
+ class Field(Element):
32
+ """
33
+ A field of values at positions in space.
34
+
35
+ SKELETON: This class structure is defined for future implementation.
36
+ Full implementation is deferred.
37
+
38
+ Attributes:
39
+ positions: Grade-1 Vector with lot=(N,) representing N sample positions
40
+ values: The field values at each position (Vector, Frame, or MultiVector)
41
+
42
+ The values must have lot matching positions.lot, so each position has
43
+ exactly one associated value.
44
+
45
+ Examples:
46
+ # Velocity field (3D vectors at 3D positions)
47
+ positions = Vector(sample_points, grade=1, metric=g) # lot=(100,)
48
+ velocities = Vector(velocity_data, grade=1, metric=g) # lot=(100,)
49
+ field = Field(positions=positions, values=velocities)
50
+
51
+ # Scalar field (scalars at positions)
52
+ temperatures = Vector(temp_data, grade=0, metric=g) # lot=(100,)
53
+ field = Field(positions=positions, values=temperatures)
54
+ """
55
+
56
+ model_config = ConfigDict(
57
+ arbitrary_types_allowed=True,
58
+ frozen=False,
59
+ )
60
+
61
+ positions: Vector
62
+ values: "Vector | Frame | MultiVector"
63
+
64
+ @model_validator(mode="after")
65
+ def _validate_field(self):
66
+ """Validate positions and values are compatible."""
67
+ # Positions must be grade-1 vectors
68
+ if self.positions.grade != 1:
69
+ raise ValueError(f"Positions must be grade-1 Vectors, got grade={self.positions.grade}")
70
+
71
+ # Lots must match
72
+ if self.positions.lot != self.values.lot:
73
+ raise ValueError(f"Positions lot {self.positions.lot} != values lot {self.values.lot}")
74
+
75
+ # Sync metric and lot from positions
76
+ object.__setattr__(self, "metric", self.positions.metric)
77
+ object.__setattr__(self, "lot", self.positions.lot)
78
+
79
+ return self
80
+
81
+ @property
82
+ def n_samples(self) -> int:
83
+ """Number of sample positions in the field."""
84
+ return self.positions.lot[0] if self.positions.lot else 1
85
+
86
+ def copy(self) -> Self:
87
+ """Create a deep copy of this field."""
88
+ return Field(
89
+ positions=self.positions.copy(),
90
+ values=self.values.copy(),
91
+ )
92
+
93
+ def with_metric(self, metric: "Metric") -> Self:
94
+ """Return a new Field with the specified metric context."""
95
+ return Field(
96
+ positions=self.positions.with_metric(metric),
97
+ values=self.values.with_metric(metric),
98
+ )
99
+
100
+ def __repr__(self) -> str:
101
+ value_type = type(self.values).__name__
102
+ return f"Field(n_samples={self.n_samples}, value_type={value_type})"
@@ -526,6 +526,51 @@ class MultiVector(CompositeElement):
526
526
 
527
527
  return unit(self)
528
528
 
529
+ def apply_similarity(
530
+ self,
531
+ S: MultiVector,
532
+ t: "Vector | None" = None,
533
+ ) -> MultiVector:
534
+ """
535
+ Apply a similarity transformation to this MultiVector.
536
+
537
+ Computes: S * self * ~S + t (translation on grade-1 component only)
538
+
539
+ The similarity versor S (from align_vectors or a rotor) is applied via
540
+ sandwich product. The optional translation t is added to the grade-1
541
+ component only (translations affect positions, not orientations).
542
+
543
+ Args:
544
+ S: Similarity versor or rotor (MultiVector with grades {0, 2})
545
+ t: Optional translation vector (grade-1). Added to grade-1 component.
546
+
547
+ Returns:
548
+ New MultiVector with the transformation applied.
549
+
550
+ Example:
551
+ S = align_vectors(u, v)
552
+ t = Vector([1, 0, 0], grade=1, metric=g)
553
+ M_transformed = M.apply_similarity(S, t)
554
+ """
555
+ from morphis.operations.products import geometric, reverse
556
+
557
+ # Sandwich product: S * self * ~S
558
+ S_rev = reverse(S)
559
+ temp = geometric(S, self)
560
+ result = geometric(temp, S_rev)
561
+
562
+ if t is not None:
563
+ # Translation applies to grade-1 component only
564
+ grade1 = result.grade_select(1)
565
+ if grade1 is not None:
566
+ new_grade1 = grade1 + t
567
+ result = MultiVector(
568
+ data={**result.data, 1: new_grade1},
569
+ metric=result.metric,
570
+ lot=result.lot,
571
+ )
572
+ return result
573
+
529
574
  # =========================================================================
530
575
  # Utility Methods
531
576
  # =========================================================================