geomech 0.2.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.
- geomech/__init__.py +128 -0
- geomech/codegen/__init__.py +29 -0
- geomech/codegen/casadi_target.py +171 -0
- geomech/codegen/emitter.py +298 -0
- geomech/codegen/energy.py +111 -0
- geomech/codegen/layout.py +553 -0
- geomech/codegen/module.py +403 -0
- geomech/codegen/numpy_target.py +186 -0
- geomech/codegen/source.py +159 -0
- geomech/core/__init__.py +0 -0
- geomech/core/base/__init__.py +27 -0
- geomech/core/base/constants.py +58 -0
- geomech/core/base/expressions.py +814 -0
- geomech/core/base/types.py +96 -0
- geomech/core/math/__init__.py +16 -0
- geomech/core/math/collecting.py +274 -0
- geomech/core/math/extract.py +721 -0
- geomech/core/math/ibp.py +221 -0
- geomech/core/operations/__init__.py +24 -0
- geomech/core/operations/addition.py +124 -0
- geomech/core/operations/calculus.py +115 -0
- geomech/core/operations/geometry.py +257 -0
- geomech/core/operations/mixins.py +201 -0
- geomech/core/operations/multiplication.py +274 -0
- geomech/core/transformations/__init__.py +29 -0
- geomech/core/transformations/expansion.py +238 -0
- geomech/core/transformations/manifold_rules.py +118 -0
- geomech/core/transformations/pulling.py +237 -0
- geomech/core/transformations/simplification.py +732 -0
- geomech/core/transformations/utils.py +118 -0
- geomech/core/transformations/vector_identities.py +207 -0
- geomech/dynamics/__init__.py +14 -0
- geomech/dynamics/eom.py +1062 -0
- geomech/dynamics/standard_form.py +251 -0
- geomech/dynamics/variables.py +41 -0
- geomech/dynamics/vbl.py +654 -0
- geomech/numeric.py +893 -0
- geomech/simulation/__init__.py +24 -0
- geomech/simulation/control.py +358 -0
- geomech/simulation/integrators.py +469 -0
- geomech/simulation/model.py +270 -0
- geomech/systems/__init__.py +98 -0
- geomech/systems/_base.py +392 -0
- geomech/systems/aerial.py +512 -0
- geomech/systems/rigid.py +408 -0
- geomech/systems/spheres.py +381 -0
- geomech/utils/__init__.py +18 -0
- geomech/utils/errors.py +107 -0
- geomech/utils/invariants.py +267 -0
- geomech/utils/printing.py +998 -0
- geomech/utils/trace.py +417 -0
- geomech/viz/__init__.py +41 -0
- geomech/viz/latex.py +168 -0
- geomech/viz/report.py +750 -0
- geomech/viz/scene.py +691 -0
- geomech/viz/template.html +688 -0
- geomech-0.2.0.dist-info/METADATA +353 -0
- geomech-0.2.0.dist-info/RECORD +61 -0
- geomech-0.2.0.dist-info/WHEEL +5 -0
- geomech-0.2.0.dist-info/licenses/LICENSE +29 -0
- geomech-0.2.0.dist-info/top_level.txt +1 -0
geomech/__init__.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
geomech is a python library for symbolic computation of Dynamics on Manifolds.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if sys.version_info < (3, 10):
|
|
8
|
+
raise ImportError("Python version >= 3.10 is required")
|
|
9
|
+
del sys
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
12
|
+
from importlib.metadata import version as _version
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = _version("geomech")
|
|
16
|
+
except _PackageNotFoundError: # running from a source tree that is not installed
|
|
17
|
+
__version__ = "unknown"
|
|
18
|
+
del _version, _PackageNotFoundError
|
|
19
|
+
|
|
20
|
+
# --- Layer 1: types & leaf nodes ---
|
|
21
|
+
from geomech.core.base import (
|
|
22
|
+
S2,
|
|
23
|
+
SO3,
|
|
24
|
+
TS2,
|
|
25
|
+
TSO3,
|
|
26
|
+
Expr,
|
|
27
|
+
ExprFlags,
|
|
28
|
+
ExprType,
|
|
29
|
+
I,
|
|
30
|
+
IdentityMatrix,
|
|
31
|
+
ManifoldInfo,
|
|
32
|
+
Matrix,
|
|
33
|
+
MatrixExpr,
|
|
34
|
+
Number,
|
|
35
|
+
O,
|
|
36
|
+
One,
|
|
37
|
+
Scalar,
|
|
38
|
+
ScalarExpr,
|
|
39
|
+
SkewSymmMatrix,
|
|
40
|
+
Vector,
|
|
41
|
+
VectorExpr,
|
|
42
|
+
Zero,
|
|
43
|
+
ZeroMatrix,
|
|
44
|
+
ZeroVector,
|
|
45
|
+
getMatrices,
|
|
46
|
+
getScalars,
|
|
47
|
+
getVectors,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# --- Layer 4: symbolic math ---
|
|
51
|
+
from geomech.core.math.collecting import collect
|
|
52
|
+
from geomech.core.math.extract import extract_linear_coeff
|
|
53
|
+
from geomech.core.math.ibp import integrate_by_parts
|
|
54
|
+
|
|
55
|
+
# --- Layer 2: expression tree nodes ---
|
|
56
|
+
from geomech.core.operations import (
|
|
57
|
+
Add,
|
|
58
|
+
Cross,
|
|
59
|
+
Dot,
|
|
60
|
+
Hat,
|
|
61
|
+
MAdd,
|
|
62
|
+
MMMul,
|
|
63
|
+
Mul,
|
|
64
|
+
MVMul,
|
|
65
|
+
SMMul,
|
|
66
|
+
SVMul,
|
|
67
|
+
TimeDerivative,
|
|
68
|
+
TimeIntegral,
|
|
69
|
+
Transpose,
|
|
70
|
+
VAdd,
|
|
71
|
+
Variation,
|
|
72
|
+
Vee,
|
|
73
|
+
VVMul,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# --- Layer 3: algorithms ---
|
|
77
|
+
from geomech.core.transformations.expansion import expand
|
|
78
|
+
from geomech.core.transformations.manifold_rules import apply_manifold_rules
|
|
79
|
+
from geomech.core.transformations.pulling import pull
|
|
80
|
+
from geomech.core.transformations.simplification import full_simplify, simplify
|
|
81
|
+
from geomech.core.transformations.vector_identities import vector_rules
|
|
82
|
+
|
|
83
|
+
# --- Layer 5: dynamics pipeline ---
|
|
84
|
+
from geomech.dynamics import (
|
|
85
|
+
LinearizedSystem,
|
|
86
|
+
StandardFormEquation,
|
|
87
|
+
SystemVariables,
|
|
88
|
+
compute_eom,
|
|
89
|
+
linearize,
|
|
90
|
+
separate_variations,
|
|
91
|
+
to_standard_form,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# --- Numeric oracle (imports only layers 1-2 itself, so it may sit anywhere after them) ---
|
|
95
|
+
from geomech.numeric import (
|
|
96
|
+
Evaluator,
|
|
97
|
+
assert_eom,
|
|
98
|
+
check_eom,
|
|
99
|
+
evaluate,
|
|
100
|
+
finite_difference_check,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# --- Utilities ---
|
|
104
|
+
from geomech.utils import (
|
|
105
|
+
display_eom,
|
|
106
|
+
display_latex,
|
|
107
|
+
display_standard_form,
|
|
108
|
+
eom_to_latex,
|
|
109
|
+
print_eom,
|
|
110
|
+
print_tree,
|
|
111
|
+
render_eom,
|
|
112
|
+
to_latex,
|
|
113
|
+
tree_str,
|
|
114
|
+
)
|
|
115
|
+
from geomech.utils.errors import (
|
|
116
|
+
AlgebraicError,
|
|
117
|
+
ExpressionMismatchError,
|
|
118
|
+
InvariantError,
|
|
119
|
+
NonLinearError,
|
|
120
|
+
SizeMismatchError,
|
|
121
|
+
)
|
|
122
|
+
from geomech.utils.invariants import check_tree
|
|
123
|
+
from geomech.utils.trace import EOMTrace, LinearizationTrace, set_trace
|
|
124
|
+
from geomech.utils.trace import configure_from_env as _configure_from_env
|
|
125
|
+
|
|
126
|
+
# GEOMECH_DEBUG=1: the "geomech" logger at DEBUG and check_tree at pipeline entry/exit;
|
|
127
|
+
# GEOMECH_TRACE=1: log every named rewrite rule (see geomech/utils/trace.py)
|
|
128
|
+
_configure_from_env()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Code generation: numeric functions from the symbolic output of the pipeline.
|
|
2
|
+
|
|
3
|
+
``StateLayout`` maps the symbols of a system to flat arrays; ``to_source``/``lambdify``
|
|
4
|
+
turn one expression into Python source or a callable; ``generate_module`` writes a module
|
|
5
|
+
with the blocks of a ``compute_eom``, ``to_standard_form`` or ``linearize`` result;
|
|
6
|
+
``energy_function`` generates the total energy of a Lagrangian. Two targets: ``"numpy"``
|
|
7
|
+
(default, no dependency beyond numpy) and ``"casadi"`` (optional extra
|
|
8
|
+
``geomech[casadi]``; imported only when that target is used). See ``docs/codegen.md``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from geomech.codegen.energy import energy_expr, energy_function, energy_source, momenta
|
|
12
|
+
from geomech.codegen.layout import Slot, StateLayout, collect_params
|
|
13
|
+
from geomech.codegen.module import generate_module, load_module, module_source
|
|
14
|
+
from geomech.codegen.source import lambdify, to_source
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"StateLayout",
|
|
18
|
+
"Slot",
|
|
19
|
+
"collect_params",
|
|
20
|
+
"to_source",
|
|
21
|
+
"lambdify",
|
|
22
|
+
"generate_module",
|
|
23
|
+
"module_source",
|
|
24
|
+
"load_module",
|
|
25
|
+
"energy_expr",
|
|
26
|
+
"energy_function",
|
|
27
|
+
"energy_source",
|
|
28
|
+
"momenta",
|
|
29
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The CasADi target: the NumPy translation table written with ``casadi`` operations.
|
|
2
|
+
|
|
3
|
+
Vectors are ``3×1`` columns, matrices ``3×3``; ``ca.mtimes`` for matrix products,
|
|
4
|
+
``ca.dot``, ``ca.cross``, ``ca.vertcat``/``ca.horzcat`` for ``_hat``/``_vee`` and the block
|
|
5
|
+
assembly. The generated functions accept NumPy arrays or CasADi expressions (``SX``,
|
|
6
|
+
``MX``, ``DM``) for ``state``, ``a``, ``u`` and the values of ``p``, and return CasADi
|
|
7
|
+
objects (``DM`` for numeric arguments), so ``ca.Function`` and ``ca.jacobian`` apply to
|
|
8
|
+
them directly. The generated module imports ``casadi`` (extra ``geomech[casadi]``);
|
|
9
|
+
``geomech`` itself does not.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from geomech.codegen.emitter import Code
|
|
15
|
+
from geomech.codegen.layout import Slot
|
|
16
|
+
from geomech.codegen.numpy_target import NumpyTarget
|
|
17
|
+
|
|
18
|
+
__all__ = ["CasadiTarget", "require_casadi"]
|
|
19
|
+
|
|
20
|
+
_HELPERS = """
|
|
21
|
+
def _is_ca(x):
|
|
22
|
+
return isinstance(x, (ca.SX, ca.MX, ca.DM))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _arr(x, n, name):
|
|
26
|
+
if x is None:
|
|
27
|
+
raise ValueError(f"{name} must have {n} entries, got None ({name} is required here)")
|
|
28
|
+
if not _is_ca(x):
|
|
29
|
+
x = ca.DM(np.asarray(x, dtype=float).reshape(-1, 1))
|
|
30
|
+
if x.numel() != n:
|
|
31
|
+
raise ValueError(f"{name} must have {n} entries, got {x.numel()}")
|
|
32
|
+
return ca.reshape(x, n, 1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scalar(x, name):
|
|
36
|
+
if _is_ca(x):
|
|
37
|
+
if x.numel() != 1:
|
|
38
|
+
raise ValueError(f"parameter {name!r} is a scalar, got {x.numel()} entries")
|
|
39
|
+
return ca.reshape(x, 1, 1)
|
|
40
|
+
x = np.asarray(x, dtype=float)
|
|
41
|
+
if x.size != 1:
|
|
42
|
+
raise ValueError(f"parameter {name!r} is a scalar, got {x.size} entries")
|
|
43
|
+
return float(x.reshape(()))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _vec(x):
|
|
47
|
+
return _arr(x, 3, "parameter")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _mat(x):
|
|
51
|
+
if _is_ca(x) and x.shape == (3, 3):
|
|
52
|
+
return x
|
|
53
|
+
if _is_ca(x):
|
|
54
|
+
return ca.reshape(x, 3, 3).T
|
|
55
|
+
return ca.DM(np.asarray(x, dtype=float).reshape(3, 3))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _hat(v):
|
|
59
|
+
return ca.vertcat(
|
|
60
|
+
ca.horzcat(0, -v[2], v[1]),
|
|
61
|
+
ca.horzcat(v[2], 0, -v[0]),
|
|
62
|
+
ca.horzcat(-v[1], v[0], 0),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _vee(M):
|
|
67
|
+
return ca.vertcat(M[2, 1], M[0, 2], M[1, 0])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_params(p, names):
|
|
71
|
+
p = {} if p is None else p
|
|
72
|
+
missing = [k for k in names if k not in p]
|
|
73
|
+
if missing:
|
|
74
|
+
raise KeyError(f"missing parameter {missing[0]!r} (expected {list(names)})")
|
|
75
|
+
extra = [k for k in p if k not in names]
|
|
76
|
+
if extra:
|
|
77
|
+
raise ValueError(f"unknown parameter {extra[0]!r} (expected {list(names)})")
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def require_casadi():
|
|
82
|
+
"""Import ``casadi`` or raise ``ImportError`` saying how to install it."""
|
|
83
|
+
try:
|
|
84
|
+
import casadi
|
|
85
|
+
except ImportError as err: # pragma: no cover - depends on the environment
|
|
86
|
+
raise ImportError(
|
|
87
|
+
"geomech.codegen: the CasADi target needs the optional dependency 'casadi';"
|
|
88
|
+
" install it with `pip install geomech[casadi]` (or `pip install casadi`)"
|
|
89
|
+
) from err
|
|
90
|
+
return casadi
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CasadiTarget(NumpyTarget):
|
|
94
|
+
"""Writes each operation of the translation table as CasADi source."""
|
|
95
|
+
|
|
96
|
+
name = "casadi"
|
|
97
|
+
imports = ("import casadi as ca", "import numpy as np")
|
|
98
|
+
helpers = _HELPERS
|
|
99
|
+
|
|
100
|
+
def literal(self, node) -> Code:
|
|
101
|
+
code = super().literal(node).text
|
|
102
|
+
code = {
|
|
103
|
+
"np.zeros((3, 3))": "ca.DM.zeros(3, 3)",
|
|
104
|
+
"np.eye(3)": "ca.DM.eye(3)",
|
|
105
|
+
"np.zeros(3)": "ca.DM.zeros(3, 1)",
|
|
106
|
+
"np.inf": "ca.inf",
|
|
107
|
+
"-np.inf": "-ca.inf",
|
|
108
|
+
}.get(code, code)
|
|
109
|
+
return Code(code, atomic=True)
|
|
110
|
+
|
|
111
|
+
def unpack(self, slot: Slot) -> str:
|
|
112
|
+
if slot.array == "p":
|
|
113
|
+
return super().unpack(slot)
|
|
114
|
+
if slot.shape == (3, 3):
|
|
115
|
+
return f"ca.reshape({slot.array}[{slot.start}:{slot.stop}], 3, 3).T"
|
|
116
|
+
return super().unpack(slot)
|
|
117
|
+
|
|
118
|
+
def matmul(self, a: Code, b: Code) -> Code:
|
|
119
|
+
return Code(f"ca.mtimes({a.text}, {b.text})", atomic=True)
|
|
120
|
+
|
|
121
|
+
matvec = matmul
|
|
122
|
+
|
|
123
|
+
def dot(self, a: Code, b: Code) -> Code:
|
|
124
|
+
return Code(f"ca.dot({a.text}, {b.text})", atomic=True)
|
|
125
|
+
|
|
126
|
+
def outer(self, a: Code, b: Code) -> Code:
|
|
127
|
+
return Code(f"ca.mtimes({a.text}, {b.operand()}.T)", atomic=True)
|
|
128
|
+
|
|
129
|
+
def cross(self, a: Code, b: Code) -> Code:
|
|
130
|
+
return Code(f"ca.cross({a.text}, {b.text})", atomic=True)
|
|
131
|
+
|
|
132
|
+
def flatten_matrix(self, a: Code) -> Code:
|
|
133
|
+
return Code(f"ca.reshape({a.operand()}.T, 9, 1)", atomic=True)
|
|
134
|
+
|
|
135
|
+
def stack(self, n: int, parts) -> list[str]:
|
|
136
|
+
if not parts:
|
|
137
|
+
return [f"return ca.DM.zeros({n}, 1)"]
|
|
138
|
+
lines, items, pos = [], [], 0
|
|
139
|
+
for start, size, code in parts:
|
|
140
|
+
if start > pos:
|
|
141
|
+
items.append(f"ca.DM.zeros({start - pos}, 1)")
|
|
142
|
+
items.append(code.text)
|
|
143
|
+
pos = start + size
|
|
144
|
+
if pos < n:
|
|
145
|
+
items.append(f"ca.DM.zeros({n - pos}, 1)")
|
|
146
|
+
lines.append("return ca.vertcat(")
|
|
147
|
+
lines += [f" {item}," for item in items]
|
|
148
|
+
return lines + [")"]
|
|
149
|
+
|
|
150
|
+
def block_matrix(self, row_spans, col_spans, blocks) -> list[str]:
|
|
151
|
+
"""``ca.vertcat`` of one ``ca.horzcat`` per block row; a missing block is a zero
|
|
152
|
+
``DM``, and a scalar row's 3-vector block is transposed to ``1×3``."""
|
|
153
|
+
if not col_spans or not row_spans:
|
|
154
|
+
n_rows, n_cols = sum(n for _, n in row_spans), sum(n for _, n in col_spans)
|
|
155
|
+
return [f"return ca.DM.zeros({n_rows}, {n_cols})"]
|
|
156
|
+
lines = []
|
|
157
|
+
for i, (_, nr) in enumerate(row_spans):
|
|
158
|
+
items = []
|
|
159
|
+
for j, (_, nc) in enumerate(col_spans):
|
|
160
|
+
code = blocks.get((i, j))
|
|
161
|
+
if code is None:
|
|
162
|
+
items.append(f"ca.DM.zeros({nr}, {nc})")
|
|
163
|
+
elif nr == 1 and nc == 3:
|
|
164
|
+
items.append(f"{code.operand()}.T")
|
|
165
|
+
else:
|
|
166
|
+
items.append(code.text)
|
|
167
|
+
lines.append(f"_r{i} = ca.horzcat(")
|
|
168
|
+
lines += [f" {item}," for item in items]
|
|
169
|
+
lines.append(")")
|
|
170
|
+
rows = ", ".join(f"_r{i}" for i in range(len(row_spans)))
|
|
171
|
+
return lines + [f"return ca.vertcat({rows})"]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Translate expression trees into the body of a generated Python function.
|
|
2
|
+
|
|
3
|
+
``Emitter`` walks a tree once per node class (the same ``match``/``case`` dispatch as the
|
|
4
|
+
rest of the engine) and produces Python source through a *target* (``NumpyTarget`` or
|
|
5
|
+
``CasadiTarget``) that knows how to write each operation. Shared by both targets:
|
|
6
|
+
|
|
7
|
+
- **Leaves** are resolved through the ``StateLayout``: configuration, velocity,
|
|
8
|
+
acceleration, input and parameter symbols are bound once at the top of the function,
|
|
9
|
+
numeric literals and the zero/identity constants are inlined. Anything else raises
|
|
10
|
+
``ValueError`` naming the leaf (the codegen analogue of the stray-symbol check).
|
|
11
|
+
- **Rows**: there is one ``VECTOR`` type; every vector, row or column, is generated as the
|
|
12
|
+
same 3-vector, and the row/column structure (``is_row_vector``) only decides between the
|
|
13
|
+
inner and the outer product of a ``VVMul`` and the side of an ``MVMul`` (``vᵀM`` is
|
|
14
|
+
``Mᵀv``).
|
|
15
|
+
- **Common subexpressions**: a subtree that occurs more than once is computed once into a
|
|
16
|
+
local ``_tN``. Subtrees are identified structurally: a leaf by its class and ``str``
|
|
17
|
+
(the engine's identity), an operation by its class and the identities of its operands.
|
|
18
|
+
The identity of each node object is computed once per emission pass (a dict keyed by
|
|
19
|
+
``id(node)``), so generation is linear in the number of distinct node objects, also on
|
|
20
|
+
trees that share subtrees, whose ``str`` grows exponentially with depth. A long
|
|
21
|
+
expression is also split into locals so that every generated line stays short (and
|
|
22
|
+
ruff-clean).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections import Counter
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, Iterable
|
|
31
|
+
|
|
32
|
+
from geomech.codegen.layout import Slot, StateLayout, base_symbol, is_literal_leaf
|
|
33
|
+
from geomech.core.base.expressions import TS2, TSO3, Matrix, Scalar, Vector
|
|
34
|
+
from geomech.core.base.types import ExprType
|
|
35
|
+
from geomech.core.operations.addition import Add, MAdd, VAdd
|
|
36
|
+
from geomech.core.operations.calculus import TimeDerivative, TimeIntegral, Variation
|
|
37
|
+
from geomech.core.operations.geometry import Cross, Dot, Hat, Transpose, Vee, is_row_vector
|
|
38
|
+
from geomech.core.operations.multiplication import MMMul, Mul, MVMul, SMMul, SVMul, VVMul
|
|
39
|
+
|
|
40
|
+
__all__ = ["ADD", "ALL_KINDS", "ATOM", "MUL", "Code", "Emitter", "pystr"]
|
|
41
|
+
|
|
42
|
+
#: Length above which a generated expression is moved into a local of its own.
|
|
43
|
+
MAX_INLINE = 72
|
|
44
|
+
#: Length above which an operand of another operation is moved into a local first.
|
|
45
|
+
MAX_OPERAND = 30
|
|
46
|
+
|
|
47
|
+
ALL_KINDS = frozenset({"configuration", "velocity", "acceleration", "input", "param"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pystr(s: str) -> str:
|
|
51
|
+
"""A double-quoted Python string literal (the form ruff format keeps)."""
|
|
52
|
+
return json.dumps(s)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: Operator precedence of generated code: a sum, a product (``*``/``@``), an atom.
|
|
56
|
+
ADD, MUL, ATOM = 1, 2, 3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Code:
|
|
61
|
+
"""A generated expression: its source and its precedence (``ADD``, ``MUL``, ``ATOM``),
|
|
62
|
+
which decides where it needs parentheses as an operand."""
|
|
63
|
+
|
|
64
|
+
text: str
|
|
65
|
+
atomic: bool = False
|
|
66
|
+
prec: int = ADD
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
if self.atomic:
|
|
70
|
+
object.__setattr__(self, "prec", ATOM)
|
|
71
|
+
|
|
72
|
+
def wrap(self, minimum: int) -> str:
|
|
73
|
+
"""The text, parenthesized when its precedence is below *minimum*."""
|
|
74
|
+
return self.text if self.prec >= minimum else f"({self.text})"
|
|
75
|
+
|
|
76
|
+
def operand(self) -> str:
|
|
77
|
+
return self.wrap(ATOM)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _children(node):
|
|
81
|
+
return getattr(node, "nodes", None) or ()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Emitter:
|
|
85
|
+
"""Generate the statements that compute a set of expression trees.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
layout: the ``StateLayout`` that resolves the leaves.
|
|
89
|
+
target: a ``NumpyTarget`` or ``CasadiTarget``.
|
|
90
|
+
kinds: the slot kinds the expressions may use (``ALL_KINDS`` by default); a leaf
|
|
91
|
+
of another kind raises ``ValueError`` (a mass block with an input in it).
|
|
92
|
+
what: a name for the expressions, used in error messages.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
layout: StateLayout,
|
|
98
|
+
target: Any,
|
|
99
|
+
kinds: Iterable[str] = ALL_KINDS,
|
|
100
|
+
what: str = "expression",
|
|
101
|
+
):
|
|
102
|
+
self.layout = layout
|
|
103
|
+
self.target = target
|
|
104
|
+
self.kinds = frozenset(kinds)
|
|
105
|
+
self.what = what
|
|
106
|
+
self.lines: list[str] = []
|
|
107
|
+
self.used: dict[str, Slot] = {}
|
|
108
|
+
self._cache: dict[int, Code] = {}
|
|
109
|
+
self._counts: Counter = Counter()
|
|
110
|
+
# id(node) -> (structural key, node); the node is kept so that its id is not reused
|
|
111
|
+
self._keys: dict[int, tuple[int, Any]] = {}
|
|
112
|
+
self._intern: dict[tuple, int] = {}
|
|
113
|
+
self._n_temps = 0
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ public
|
|
116
|
+
|
|
117
|
+
def prepare(self, exprs) -> None:
|
|
118
|
+
"""Count the subtrees of every expression of the function (for CSE)."""
|
|
119
|
+
for e in exprs:
|
|
120
|
+
self._count(e)
|
|
121
|
+
|
|
122
|
+
def emit(self, node) -> Code:
|
|
123
|
+
"""The code of *node*; statements it needs are appended to ``self.lines``."""
|
|
124
|
+
key = self._key(node)
|
|
125
|
+
if key in self._cache:
|
|
126
|
+
return self._cache[key]
|
|
127
|
+
code = self._dispatch(node)
|
|
128
|
+
if _children(node) and (self._counts[key] > 1 or len(code.text) > MAX_INLINE):
|
|
129
|
+
code = self.assign(code)
|
|
130
|
+
self._cache[key] = code
|
|
131
|
+
return code
|
|
132
|
+
|
|
133
|
+
def assign(self, code: Code) -> Code:
|
|
134
|
+
"""Bind *code* to a new local and return the local's name."""
|
|
135
|
+
if code.atomic and code.text.isidentifier():
|
|
136
|
+
return code
|
|
137
|
+
name = f"_t{self._n_temps}"
|
|
138
|
+
self._n_temps += 1
|
|
139
|
+
self.lines.append(f"{name} = {code.text}")
|
|
140
|
+
return Code(name, atomic=True)
|
|
141
|
+
|
|
142
|
+
def preamble(self) -> list[str]:
|
|
143
|
+
"""The statements that bind every slot the expressions used, in layout order."""
|
|
144
|
+
order = {key: i for i, key in enumerate(s.key for s in self.layout)}
|
|
145
|
+
slots = sorted(self.used.values(), key=lambda s: order[s.key])
|
|
146
|
+
arrays = [a for a in ("state", "a", "u") if any(s.array == a for s in slots)]
|
|
147
|
+
lines = [self.target.convert_array(a, self._array_size(a)) for a in arrays]
|
|
148
|
+
lines += [f"{s.name} = {self.target.unpack(s)}" for s in slots]
|
|
149
|
+
return lines
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------ counting
|
|
152
|
+
|
|
153
|
+
def _key(self, node) -> int:
|
|
154
|
+
"""The structural identity of *node*: equal for equal subtrees (see the module
|
|
155
|
+
docstring), computed once per node object."""
|
|
156
|
+
hit = self._keys.get(id(node))
|
|
157
|
+
if hit is not None:
|
|
158
|
+
return hit[0]
|
|
159
|
+
children = _children(node)
|
|
160
|
+
if children:
|
|
161
|
+
sig: tuple = (type(node), tuple(self._key(c) for c in children))
|
|
162
|
+
else:
|
|
163
|
+
sig = (type(node), str(node))
|
|
164
|
+
key = self._intern.setdefault(sig, len(self._intern))
|
|
165
|
+
self._keys[id(node)] = (key, node)
|
|
166
|
+
return key
|
|
167
|
+
|
|
168
|
+
def _count(self, node):
|
|
169
|
+
key = self._key(node)
|
|
170
|
+
self._counts[key] += 1
|
|
171
|
+
if self._counts[key] == 1:
|
|
172
|
+
for child in _children(node):
|
|
173
|
+
self._count(child)
|
|
174
|
+
|
|
175
|
+
def _array_size(self, array):
|
|
176
|
+
return {"state": self.layout.n_state, "a": self.layout.n_accel}.get(
|
|
177
|
+
array, self.layout.n_inputs
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------ dispatch
|
|
181
|
+
|
|
182
|
+
def _dispatch(self, node) -> Code:
|
|
183
|
+
t = self.target
|
|
184
|
+
match node:
|
|
185
|
+
case Add() | VAdd() | MAdd():
|
|
186
|
+
return self._sum(node)
|
|
187
|
+
case Mul() | SVMul() | SMMul():
|
|
188
|
+
return t.scale(self._op(node.left), self._op(node.right))
|
|
189
|
+
case MVMul() if is_row_vector(node.left):
|
|
190
|
+
return t.matvec(t.transpose_matrix(self._op(node.right)), self._op(node.left))
|
|
191
|
+
case MVMul() | MMMul():
|
|
192
|
+
return t.matmul(self._op(node.left), self._op(node.right))
|
|
193
|
+
case VVMul() if node.type == ExprType.SCALAR:
|
|
194
|
+
return t.dot(self._op(node.left), self._op(node.right))
|
|
195
|
+
case VVMul():
|
|
196
|
+
return t.outer(self._op(node.left), self._op(node.right))
|
|
197
|
+
case Dot():
|
|
198
|
+
return t.dot(self._op(node.left), self._op(node.right))
|
|
199
|
+
case Cross():
|
|
200
|
+
return t.cross(self._op(node.left), self._op(node.right))
|
|
201
|
+
case Hat():
|
|
202
|
+
return t.call("_hat", self._op(node.expr))
|
|
203
|
+
case Vee():
|
|
204
|
+
return t.call("_vee", self._op(node.expr))
|
|
205
|
+
case Transpose():
|
|
206
|
+
return self._transpose(node)
|
|
207
|
+
case TimeDerivative():
|
|
208
|
+
return self._time_derivative(node)
|
|
209
|
+
case Variation() | TimeIntegral():
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"codegen: {self.what} contains the {type(node).__name__} '{node}'; only"
|
|
212
|
+
" configuration, velocity, acceleration, input and parameter symbols can"
|
|
213
|
+
" be generated (a variation or an integral has no numeric value here)"
|
|
214
|
+
)
|
|
215
|
+
case Scalar() | Vector() | Matrix():
|
|
216
|
+
return self._leaf(node)
|
|
217
|
+
case _:
|
|
218
|
+
raise TypeError(
|
|
219
|
+
f"codegen: no translation for the node class {type(node).__name__} '{node}'"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _op(self, node) -> Code:
|
|
223
|
+
"""The code of an operand, moved into a local when it is long (short lines)."""
|
|
224
|
+
code = self.emit(node)
|
|
225
|
+
if not code.atomic and len(code.text) > MAX_OPERAND:
|
|
226
|
+
code = self.assign(code)
|
|
227
|
+
return code
|
|
228
|
+
|
|
229
|
+
def _sum(self, node) -> Code:
|
|
230
|
+
"""``a + b + …``; a long sum is accumulated in pieces that each fit on a line."""
|
|
231
|
+
codes = [self._op(n) for n in node.nodes]
|
|
232
|
+
parts = [codes[0].wrap(ADD)] + [c.wrap(MUL) for c in codes[1:]]
|
|
233
|
+
text = parts[0]
|
|
234
|
+
for part in parts[1:]:
|
|
235
|
+
if len(text) + 3 + len(part) > MAX_INLINE:
|
|
236
|
+
text = self.assign(Code(text)).text
|
|
237
|
+
text = f"{text} + {part}"
|
|
238
|
+
return Code(text)
|
|
239
|
+
|
|
240
|
+
def _transpose(self, node) -> Code:
|
|
241
|
+
inner = self._op(node.expr)
|
|
242
|
+
if node.expr.type == ExprType.MATRIX:
|
|
243
|
+
return self.target.transpose_matrix(inner)
|
|
244
|
+
return inner # a scalar, or the row vᵀ of a column: one 3-vector either way
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ leaves
|
|
247
|
+
|
|
248
|
+
def _time_derivative(self, node) -> Code:
|
|
249
|
+
base = base_symbol(node)
|
|
250
|
+
if base is not None and self.layout.slot(node) is not None:
|
|
251
|
+
return self._slot(node)
|
|
252
|
+
if base is not None:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"codegen: {self.what} contains '{node}', the time derivative of the"
|
|
255
|
+
f" leaf '{base}', which is neither a velocity nor an acceleration of"
|
|
256
|
+
" the layout (list a prescribed rate in params)"
|
|
257
|
+
)
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"codegen: {self.what} contains '{node}', a TimeDerivative of the non-leaf"
|
|
260
|
+
f" expression '{node.expr}'; only the time derivative of a configuration"
|
|
261
|
+
" variable or of a velocity can be generated (expand the derivative first)"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _leaf(self, node) -> Code:
|
|
265
|
+
if is_literal_leaf(node):
|
|
266
|
+
return self.target.literal(node)
|
|
267
|
+
if self.layout.slot(node) is None and isinstance(node, (TS2, TSO3)):
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"codegen: {self.what} contains '{node}', a tangent or variation vector that"
|
|
270
|
+
" is not a velocity of the layout; only configuration, velocity,"
|
|
271
|
+
" acceleration, input and parameter symbols can be generated (a variation"
|
|
272
|
+
" vector such as ξ or η, the Variation of a manifold point, has no numeric"
|
|
273
|
+
" value here, and is not a parameter)"
|
|
274
|
+
)
|
|
275
|
+
if self.layout.slot(node) is None:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"codegen: {self.what} contains the symbol '{node}' ({type(node).__name__}),"
|
|
278
|
+
" which is not a configuration, velocity, acceleration, input or parameter"
|
|
279
|
+
" of the layout; add it to params (a constant) or inputs"
|
|
280
|
+
)
|
|
281
|
+
return self._slot(node)
|
|
282
|
+
|
|
283
|
+
def _slot(self, node) -> Code:
|
|
284
|
+
slot = self.layout.slot(node)
|
|
285
|
+
assert slot is not None
|
|
286
|
+
if slot.symbol.type != node.type:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"codegen: '{node}' is a {node.type.name} in the {self.what} but the layout's"
|
|
289
|
+
f" {slot.kind} '{slot.key}' is a {slot.symbol.type.name}"
|
|
290
|
+
)
|
|
291
|
+
if slot.kind not in self.kinds:
|
|
292
|
+
allowed = ", ".join(sorted(self.kinds))
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"codegen: {self.what} contains the {slot.kind} '{slot.key}'; it may only"
|
|
295
|
+
f" depend on: {allowed}"
|
|
296
|
+
)
|
|
297
|
+
self.used[slot.key] = slot
|
|
298
|
+
return Code(slot.name, atomic=True)
|