pycodemath 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.
- pycodemath/__init__.py +91 -0
- pycodemath/__main__.py +10 -0
- pycodemath/bench/__init__.py +0 -0
- pycodemath/bench/benchmark.py +61 -0
- pycodemath/cli/__init__.py +0 -0
- pycodemath/cli/mcp_server.py +82 -0
- pycodemath/cli/repl.py +620 -0
- pycodemath/codegen/__init__.py +0 -0
- pycodemath/codegen/emit.py +554 -0
- pycodemath/codegen/pipeline.py +139 -0
- pycodemath/core/__init__.py +0 -0
- pycodemath/core/errors.py +15 -0
- pycodemath/core/ir.py +307 -0
- pycodemath/engine/__init__.py +0 -0
- pycodemath/engine/linalg.py +197 -0
- pycodemath/engine/numerics.py +973 -0
- pycodemath/engine/ode.py +1279 -0
- pycodemath/engine/symbolic.py +204 -0
- pycodemath/frontend/__init__.py +0 -0
- pycodemath/frontend/parser.py +299 -0
- pycodemath-0.2.0.dist-info/METADATA +327 -0
- pycodemath-0.2.0.dist-info/RECORD +26 -0
- pycodemath-0.2.0.dist-info/WHEEL +5 -0
- pycodemath-0.2.0.dist-info/entry_points.txt +3 -0
- pycodemath-0.2.0.dist-info/licenses/LICENSE +21 -0
- pycodemath-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Pycodemath exceptions — one lightweight, shared domain error type.
|
|
2
|
+
|
|
3
|
+
Goal: the user/agent and the REPL get a readable Pycodemath message instead of
|
|
4
|
+
a raw SymPy exception (``SympifyError``, ``TypeError`` from ``float``, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PycodemathError(Exception):
|
|
11
|
+
"""Pycodemath domain error (parsing, evaluation, generation).
|
|
12
|
+
|
|
13
|
+
Always carries a concise, readable message; the original exception is
|
|
14
|
+
attached via ``raise ... from exc`` for inspection while debugging.
|
|
15
|
+
"""
|
pycodemath/core/ir.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Pycodemath IR — the shared expression tree (contract between the engine and the generator).
|
|
2
|
+
|
|
3
|
+
The cornerstone of the project. Both module 1 (the math engine) and module 2
|
|
4
|
+
(the code generator) operate on the same ``Expr`` object. It is a thin but
|
|
5
|
+
proprietary layer over SymPy expressions: it gives us a concise API, a stable
|
|
6
|
+
interface, and a place where we will later hook in the masking stage.
|
|
7
|
+
|
|
8
|
+
The "token-cutting" goal: writing math should be short (``E("sin(x)*x")``),
|
|
9
|
+
and expansion into code happens only in the generator.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from typing import Callable, Iterable
|
|
16
|
+
|
|
17
|
+
import sympy as sp
|
|
18
|
+
|
|
19
|
+
from .errors import PycodemathError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache(maxsize=512)
|
|
23
|
+
def _lambdify_cached(sy: sp.Expr, syms: tuple) -> Callable:
|
|
24
|
+
"""Compile (and memoize) a SymPy expression into a numeric function.
|
|
25
|
+
|
|
26
|
+
``lambdify`` is code generation + exec — a single call costs ~ms, so
|
|
27
|
+
without a cache, methods called repeatedly on the same expression (loops,
|
|
28
|
+
tests, REPL) would pay for compilation every time. The key (expression,
|
|
29
|
+
symbols) is hashable, because SymPy expressions are immutable.
|
|
30
|
+
"""
|
|
31
|
+
return sp.lambdify(syms, sy, modules=["math", "numpy"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Expr:
|
|
35
|
+
"""Wrapper around a SymPy expression with its own concise API.
|
|
36
|
+
|
|
37
|
+
We keep a ``sympy.Expr`` inside (``self.sy``). All mathematical operations
|
|
38
|
+
are delegated to SymPy, but the user/agent sees only the stable Pycodemath
|
|
39
|
+
interface.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("sy",)
|
|
43
|
+
|
|
44
|
+
sy: "sp.Expr"
|
|
45
|
+
|
|
46
|
+
def __init__(self, expr: "sp.Expr | Expr | str | int | float"):
|
|
47
|
+
if isinstance(expr, Expr):
|
|
48
|
+
self.sy = expr.sy
|
|
49
|
+
else:
|
|
50
|
+
# str, int, float, sympy.Expr — a single entry point via sympify.
|
|
51
|
+
# Note: for ``str`` this is SymPy evaluation (the same surface as
|
|
52
|
+
# in the parser) — do not feed it untrusted text without being
|
|
53
|
+
# aware that sympify is NOT a sandbox.
|
|
54
|
+
self.sy = sp.sympify(expr)
|
|
55
|
+
|
|
56
|
+
# --- introspection --------------------------------------------------
|
|
57
|
+
@property
|
|
58
|
+
def free_symbols(self) -> tuple[sp.Symbol, ...]:
|
|
59
|
+
"""Symbols (variables) occurring in the expression, in a stable order."""
|
|
60
|
+
return tuple(sorted(self.sy.free_symbols, key=lambda s: s.name))
|
|
61
|
+
|
|
62
|
+
def symbol_names(self) -> list[str]:
|
|
63
|
+
return [s.name for s in self.free_symbols]
|
|
64
|
+
|
|
65
|
+
# --- symbolic operations (delegated, but return Expr) ---------------
|
|
66
|
+
def simplify(self) -> "Expr":
|
|
67
|
+
return Expr(sp.simplify(self.sy))
|
|
68
|
+
|
|
69
|
+
def expand(self) -> "Expr":
|
|
70
|
+
return Expr(sp.expand(self.sy))
|
|
71
|
+
|
|
72
|
+
def diff(self, var: str) -> "Expr":
|
|
73
|
+
return Expr(sp.diff(self.sy, sp.Symbol(var)))
|
|
74
|
+
|
|
75
|
+
def integrate(self, var: str) -> "Expr":
|
|
76
|
+
res = sp.integrate(self.sy, sp.Symbol(var))
|
|
77
|
+
# An unevaluated Integral in the result is junk that breaks the contract
|
|
78
|
+
# (analogous to the res.has(sp.Sum) guard in summation) — we refuse
|
|
79
|
+
# clearly.
|
|
80
|
+
if res.has(sp.Integral):
|
|
81
|
+
raise PycodemathError(
|
|
82
|
+
"integrate: no closed form — compute numerically "
|
|
83
|
+
"or simplify the expression"
|
|
84
|
+
)
|
|
85
|
+
return Expr(res)
|
|
86
|
+
|
|
87
|
+
def subs(self, mapping: dict[str, "float | int | str"]) -> "Expr":
|
|
88
|
+
# String values go through the guarded parser (whitelist of tokens
|
|
89
|
+
# and names), NOT through bare sympify — otherwise subs would be a
|
|
90
|
+
# second sympify surface with the full namespace, bypassing the parser.
|
|
91
|
+
# Local import: frontend.parser imports core.ir (cycle).
|
|
92
|
+
from ..frontend.parser import parse
|
|
93
|
+
|
|
94
|
+
m = {
|
|
95
|
+
sp.Symbol(k): parse(v).sy if isinstance(v, str) else sp.sympify(v)
|
|
96
|
+
for k, v in mapping.items()
|
|
97
|
+
}
|
|
98
|
+
return Expr(self.sy.subs(m))
|
|
99
|
+
|
|
100
|
+
def evalf(self, **values: float) -> float:
|
|
101
|
+
"""Numeric evaluation after substituting values for symbols."""
|
|
102
|
+
m = {sp.Symbol(k): v for k, v in values.items()}
|
|
103
|
+
result = self.sy.evalf(subs=m)
|
|
104
|
+
try:
|
|
105
|
+
return float(result)
|
|
106
|
+
except TypeError as exc:
|
|
107
|
+
# Complex or still-symbolic result — a readable error instead of a
|
|
108
|
+
# raw TypeError from ``float()``.
|
|
109
|
+
raise PycodemathError(
|
|
110
|
+
f"cannot return a real number from {result} "
|
|
111
|
+
f"(complex result or unsubstituted symbols)"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
def compiled(self, vars: "Iterable[str]") -> Callable:
|
|
115
|
+
"""Compile the expression into a fast numeric function (``lambdify``).
|
|
116
|
+
|
|
117
|
+
Numeric engine contract: compile ONCE before the loop, iterate without
|
|
118
|
+
SymPy overhead (``evalf`` remains the contract for a SINGLE evaluation).
|
|
119
|
+
``math`` first — domain errors are a plain ``ValueError``, scalars are
|
|
120
|
+
fast; ``numpy`` as a fallback for functions that math lacks. The
|
|
121
|
+
returned function takes values positionally, in the order of ``vars``.
|
|
122
|
+
Compilations are cached (LRU) by the (expression, symbols) pair.
|
|
123
|
+
"""
|
|
124
|
+
return _lambdify_cached(self.sy, tuple(sp.Symbol(v) for v in vars))
|
|
125
|
+
|
|
126
|
+
# --- serialization (concise, token-friendly) ------------------------
|
|
127
|
+
def to_source(self) -> str:
|
|
128
|
+
"""Short, unambiguous text form — round-trips with the parser."""
|
|
129
|
+
return str(self.sy)
|
|
130
|
+
|
|
131
|
+
def equivalent(self, other: "Expr | str | int | float") -> bool:
|
|
132
|
+
"""Mathematical equality: does ``self - other`` simplify to zero.
|
|
133
|
+
|
|
134
|
+
Slower than ``==`` (runs ``simplify``), but catches the equivalence of
|
|
135
|
+
expressions with different structure, e.g. ``(x+1)**2`` and
|
|
136
|
+
``x**2 + 2*x + 1``. ``==`` remains a structural comparison, consistent
|
|
137
|
+
with ``__hash__``.
|
|
138
|
+
"""
|
|
139
|
+
other = other if isinstance(other, Expr) else Expr(other)
|
|
140
|
+
return bool(sp.simplify(self.sy - other.sy) == 0)
|
|
141
|
+
|
|
142
|
+
def __eq__(self, other: object) -> bool:
|
|
143
|
+
# STRUCTURAL comparison (consistent with ``__hash__``) — fast and
|
|
144
|
+
# safe for set/dict. For mathematical equality: ``equivalent``.
|
|
145
|
+
if isinstance(other, Expr):
|
|
146
|
+
return bool(self.sy == other.sy)
|
|
147
|
+
return NotImplemented
|
|
148
|
+
|
|
149
|
+
def __hash__(self) -> int:
|
|
150
|
+
return hash(self.sy)
|
|
151
|
+
|
|
152
|
+
def __repr__(self) -> str:
|
|
153
|
+
return f"Expr({self.sy!s})"
|
|
154
|
+
|
|
155
|
+
def __str__(self) -> str:
|
|
156
|
+
return str(self.sy)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Matrix:
|
|
160
|
+
"""Wrapper around a SymPy matrix/vector — IR extension for linear algebra.
|
|
161
|
+
|
|
162
|
+
We keep a ``sympy.MatrixBase`` inside (``self.sy``). A vector is simply an
|
|
163
|
+
``n × 1`` column matrix. The notation stays concise: ``M("[[1,2],[3,4]]")``,
|
|
164
|
+
``V("[1,2,3]")`` — while the heavy operations are done by the engine
|
|
165
|
+
(``engine.linalg``).
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
__slots__ = ("sy",)
|
|
169
|
+
|
|
170
|
+
sy: "sp.MatrixBase"
|
|
171
|
+
|
|
172
|
+
def __init__(self, data: "sp.MatrixBase | Matrix | str | list | tuple"):
|
|
173
|
+
if isinstance(data, Matrix):
|
|
174
|
+
self.sy = data.sy
|
|
175
|
+
elif isinstance(data, sp.MatrixBase):
|
|
176
|
+
self.sy = data
|
|
177
|
+
elif isinstance(data, str):
|
|
178
|
+
self.sy = _parse_matrix(data)
|
|
179
|
+
else:
|
|
180
|
+
try:
|
|
181
|
+
self.sy = sp.Matrix(data)
|
|
182
|
+
except (ValueError, TypeError) as exc:
|
|
183
|
+
# e.g. rows of different lengths — a readable error instead of a
|
|
184
|
+
# raw ValueError from deep inside SymPy (contract: always PycodemathError)
|
|
185
|
+
raise PycodemathError(f"invalid matrix: {exc}") from exc
|
|
186
|
+
|
|
187
|
+
# --- introspection --------------------------------------------------
|
|
188
|
+
@property
|
|
189
|
+
def shape(self) -> tuple[int, int]:
|
|
190
|
+
return self.sy.shape
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def rows(self) -> int:
|
|
194
|
+
return self.sy.rows
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def cols(self) -> int:
|
|
198
|
+
return self.sy.cols
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def free_symbols(self) -> tuple[sp.Symbol, ...]:
|
|
202
|
+
return tuple(sorted(self.sy.free_symbols, key=lambda s: s.name))
|
|
203
|
+
|
|
204
|
+
def symbol_names(self) -> list[str]:
|
|
205
|
+
return [s.name for s in self.free_symbols]
|
|
206
|
+
|
|
207
|
+
def tolist(self) -> list[list]:
|
|
208
|
+
return self.sy.tolist()
|
|
209
|
+
|
|
210
|
+
# --- operations (delegated, but return IR) --------------------------
|
|
211
|
+
@property
|
|
212
|
+
def T(self) -> "Matrix":
|
|
213
|
+
"""Transpose."""
|
|
214
|
+
return Matrix(self.sy.T)
|
|
215
|
+
|
|
216
|
+
def __matmul__(self, other: "Matrix") -> "Matrix":
|
|
217
|
+
return Matrix(self.sy * Matrix(other).sy)
|
|
218
|
+
|
|
219
|
+
def __mul__(self, other: "Matrix | int | float | Expr") -> "Matrix":
|
|
220
|
+
if isinstance(other, Matrix):
|
|
221
|
+
return Matrix(self.sy * other.sy)
|
|
222
|
+
if isinstance(other, Expr):
|
|
223
|
+
return Matrix(self.sy * other.sy)
|
|
224
|
+
return Matrix(self.sy * sp.sympify(other))
|
|
225
|
+
|
|
226
|
+
__rmul__ = __mul__
|
|
227
|
+
|
|
228
|
+
def __add__(self, other: "Matrix") -> "Matrix":
|
|
229
|
+
return Matrix(self.sy + Matrix(other).sy)
|
|
230
|
+
|
|
231
|
+
def __sub__(self, other: "Matrix") -> "Matrix":
|
|
232
|
+
return Matrix(self.sy - Matrix(other).sy)
|
|
233
|
+
|
|
234
|
+
def __getitem__(self, key):
|
|
235
|
+
return self.sy[key]
|
|
236
|
+
|
|
237
|
+
def __iter__(self):
|
|
238
|
+
return iter(self.sy)
|
|
239
|
+
|
|
240
|
+
# --- serialization / comparison -------------------------------------
|
|
241
|
+
def to_source(self) -> str:
|
|
242
|
+
"""Concise literal form: ``[[1, 2], [3, 4]]`` (round-trips with ``M``)."""
|
|
243
|
+
return str(self.sy.tolist())
|
|
244
|
+
|
|
245
|
+
def equivalent(self, other: "Matrix | sp.MatrixBase | str | list") -> bool:
|
|
246
|
+
"""MATHEMATICAL equality: does ``self - other`` simplify to zero.
|
|
247
|
+
|
|
248
|
+
Slower than ``==`` (runs ``simplify`` on the entries), but catches the
|
|
249
|
+
equivalence of matrices with differently structured entries. ``==``
|
|
250
|
+
remains a structural comparison, consistent with ``__hash__``
|
|
251
|
+
(analogous to ``Expr``).
|
|
252
|
+
"""
|
|
253
|
+
other = other if isinstance(other, Matrix) else Matrix(other)
|
|
254
|
+
if self.sy.shape != other.sy.shape:
|
|
255
|
+
return False
|
|
256
|
+
diff = (self.sy - other.sy).applyfunc(sp.simplify)
|
|
257
|
+
return bool(diff.is_zero_matrix)
|
|
258
|
+
|
|
259
|
+
def __eq__(self, other: object) -> bool:
|
|
260
|
+
# STRUCTURAL comparison (consistent with ``__hash__``) — fast and safe
|
|
261
|
+
# for set/dict. For mathematical equality: ``equivalent``.
|
|
262
|
+
if isinstance(other, Matrix):
|
|
263
|
+
return bool(self.sy == other.sy)
|
|
264
|
+
return NotImplemented
|
|
265
|
+
|
|
266
|
+
def __hash__(self) -> int:
|
|
267
|
+
# A matrix is mutable, but we hash its immutable counterpart —
|
|
268
|
+
# "structurally equal → equal hash", as for ``Expr``.
|
|
269
|
+
return hash(sp.ImmutableMatrix(self.sy))
|
|
270
|
+
|
|
271
|
+
def __repr__(self) -> str:
|
|
272
|
+
return f"Matrix({self.sy.tolist()!r})"
|
|
273
|
+
|
|
274
|
+
def __str__(self) -> str:
|
|
275
|
+
return str(self.sy)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_matrix(source: str) -> sp.MatrixBase:
|
|
279
|
+
"""Parse a matrix/vector literal: ``[[1,2],[3,4]]`` or ``[1,2,3]``."""
|
|
280
|
+
try:
|
|
281
|
+
obj = sp.sympify(source)
|
|
282
|
+
return sp.Matrix(obj)
|
|
283
|
+
except (sp.SympifyError, ValueError, TypeError) as exc:
|
|
284
|
+
raise PycodemathError(f"cannot parse matrix {source!r}: {exc}") from exc
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def E(source: "str | sp.Expr | Expr | int | float") -> Expr:
|
|
288
|
+
"""IR constructor shortcut: ``E("sin(x)*x")``."""
|
|
289
|
+
return Expr(source)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def M(data: "sp.MatrixBase | Matrix | str | list | tuple") -> Matrix:
|
|
293
|
+
"""IR matrix constructor shortcut: ``M("[[1,2],[3,4]]")``."""
|
|
294
|
+
return Matrix(data)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def V(data: "sp.MatrixBase | Matrix | str | list | tuple") -> Matrix:
|
|
298
|
+
"""IR vector constructor shortcut (column matrix): ``V("[1,2,3]")``."""
|
|
299
|
+
return Matrix(data)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def symbols(names: "str | Iterable[str]") -> tuple[Expr, ...]:
|
|
303
|
+
"""Create symbols as IR expressions: ``x, y = symbols("x y")``."""
|
|
304
|
+
syms = sp.symbols(names)
|
|
305
|
+
if not isinstance(syms, (tuple, list)):
|
|
306
|
+
syms = (syms,)
|
|
307
|
+
return tuple(Expr(s) for s in syms)
|
|
File without changes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Linear algebra engine (module 3) — matrices and vectors on IR.
|
|
2
|
+
|
|
3
|
+
Operates on ``Matrix`` from the IR core. By default it computes symbolically
|
|
4
|
+
(SymPy — exact, rational results, with parameters), and when that is not
|
|
5
|
+
possible (or when you explicitly ask for ``numeric=True``) — it falls back to
|
|
6
|
+
a numeric NumPy path.
|
|
7
|
+
|
|
8
|
+
Operations: multiplication, transpose, determinant, inverse, solving systems
|
|
9
|
+
``A x = b``, and eigenvalues and eigenvectors.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import sympy as sp
|
|
16
|
+
|
|
17
|
+
try: # SymPy >= 1.13
|
|
18
|
+
from sympy.matrices.exceptions import MatrixError
|
|
19
|
+
except ImportError: # SymPy 1.12
|
|
20
|
+
from sympy.matrices.common import MatrixError
|
|
21
|
+
|
|
22
|
+
from ..core.errors import PycodemathError
|
|
23
|
+
from ..core.ir import Expr, Matrix
|
|
24
|
+
|
|
25
|
+
# Exceptions after which we may drop to the numeric fallback: these are
|
|
26
|
+
# signals that "SymPy cannot compute this symbolically" (singularity, wrong
|
|
27
|
+
# shape, missing algorithm). Everything else (e.g. a programmer error) should
|
|
28
|
+
# blow up loudly, not vanish into the fallback.
|
|
29
|
+
_FALLBACK_ERRORS = (MatrixError, ValueError, TypeError, NotImplementedError)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _mat(x: "Matrix | sp.MatrixBase | str | list") -> Matrix:
|
|
33
|
+
return x if isinstance(x, Matrix) else Matrix(x)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _to_float(m: Matrix) -> np.ndarray:
|
|
37
|
+
"""Cast an IR matrix to a ``float`` array (for the numeric path).
|
|
38
|
+
|
|
39
|
+
The numeric path requires concrete numbers — a matrix with parameters would
|
|
40
|
+
raise a raw ``TypeError`` from deep inside NumPy, so instead we raise a
|
|
41
|
+
readable error.
|
|
42
|
+
"""
|
|
43
|
+
if m.sy.free_symbols:
|
|
44
|
+
names = ", ".join(sorted(s.name for s in m.sy.free_symbols))
|
|
45
|
+
raise PycodemathError(
|
|
46
|
+
f"the numeric path requires a matrix without symbols; parameters given: {names}"
|
|
47
|
+
)
|
|
48
|
+
return np.array(m.sy.tolist(), dtype=float)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _num_to_expr(v) -> Expr:
|
|
52
|
+
"""Wrap a number (float/complex from NumPy) back into a scalar IR."""
|
|
53
|
+
v = complex(v)
|
|
54
|
+
if abs(v.imag) < 1e-12:
|
|
55
|
+
return Expr(sp.Float(v.real))
|
|
56
|
+
return Expr(sp.Float(v.real) + sp.Float(v.imag) * sp.I)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _numeric(fn, *args, **kwargs):
|
|
60
|
+
"""Run an ``np.linalg`` operation, translating ``LinAlgError`` to the contract.
|
|
61
|
+
|
|
62
|
+
The numeric path on a singular/ill-conditioned matrix (or one whose
|
|
63
|
+
spectrum does not converge) raises a raw ``numpy.linalg.LinAlgError`` — in
|
|
64
|
+
English and outside the contract. We catch it and return a consistent
|
|
65
|
+
``PycodemathError``, like the rest of the engine (cf. ``numerics`` with a
|
|
66
|
+
singular Jacobian).
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
return fn(*args, **kwargs)
|
|
70
|
+
except np.linalg.LinAlgError as exc:
|
|
71
|
+
raise PycodemathError(
|
|
72
|
+
f"numeric linear algebra failed "
|
|
73
|
+
f"(singular or ill-conditioned matrix): {exc}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- basic operations --------------------------------------------------
|
|
78
|
+
def multiply(a, b) -> Matrix:
|
|
79
|
+
"""Matrix product ``A · B`` (also works for matrix × vector)."""
|
|
80
|
+
A, B = _mat(a), _mat(b)
|
|
81
|
+
# a mismatched shape used to raise a raw sympy.ShapeError outside the contract
|
|
82
|
+
if A.sy.cols != B.sy.rows:
|
|
83
|
+
raise PycodemathError(
|
|
84
|
+
f"multiply: incompatible dimensions {A.sy.rows}×{A.sy.cols} · "
|
|
85
|
+
f"{B.sy.rows}×{B.sy.cols} — the number of columns of A must equal "
|
|
86
|
+
f"the number of rows of B"
|
|
87
|
+
)
|
|
88
|
+
return Matrix(A.sy * B.sy)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def transpose(a) -> Matrix:
|
|
92
|
+
"""Transpose ``Aᵀ``."""
|
|
93
|
+
return Matrix(_mat(a).sy.T)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def det(a, numeric: bool = False) -> Expr:
|
|
97
|
+
"""Determinant ``det(A)`` (IR scalar). Symbolic, with a numeric fallback."""
|
|
98
|
+
A = _mat(a)
|
|
99
|
+
# a non-square matrix used to raise a raw NonSquareMatrixError, and after
|
|
100
|
+
# the numeric fallback — a misleading message about singularity
|
|
101
|
+
if A.sy.rows != A.sy.cols:
|
|
102
|
+
raise PycodemathError(
|
|
103
|
+
f"det: the matrix must be square, but its dimensions are "
|
|
104
|
+
f"{A.sy.rows}×{A.sy.cols}"
|
|
105
|
+
)
|
|
106
|
+
if not numeric:
|
|
107
|
+
try:
|
|
108
|
+
return Expr(A.sy.det())
|
|
109
|
+
except _FALLBACK_ERRORS:
|
|
110
|
+
pass
|
|
111
|
+
return _num_to_expr(_numeric(np.linalg.det, _to_float(A)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def inv(a, numeric: bool = False) -> Matrix:
|
|
115
|
+
"""Inverse matrix ``A⁻¹``. Symbolic, with a numeric fallback."""
|
|
116
|
+
A = _mat(a)
|
|
117
|
+
if not numeric:
|
|
118
|
+
try:
|
|
119
|
+
return Matrix(A.sy.inv())
|
|
120
|
+
except _FALLBACK_ERRORS:
|
|
121
|
+
pass
|
|
122
|
+
return Matrix(sp.Matrix(_numeric(np.linalg.inv, _to_float(A))))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- systems of equations ----------------------------------------------
|
|
126
|
+
def solve_system(A, b, numeric: bool = False) -> Matrix:
|
|
127
|
+
"""Solve the system ``A x = b`` and return the solution vector ``x``.
|
|
128
|
+
|
|
129
|
+
``b`` may be a vector (column) or a list. For a square matrix — the exact
|
|
130
|
+
solution (``LUsolve`` / ``numpy.linalg.solve``). For an overdetermined
|
|
131
|
+
system (more equations than unknowns) — the least-squares solution
|
|
132
|
+
(``solve_least_squares`` / ``numpy.linalg.lstsq``). Symbolic, with a
|
|
133
|
+
numeric NumPy fallback.
|
|
134
|
+
"""
|
|
135
|
+
A = _mat(A)
|
|
136
|
+
b = _mat(b)
|
|
137
|
+
# a mismatched number of rows used to produce, after the fallback, a
|
|
138
|
+
# misleading message about parameters/singularity instead of pointing to
|
|
139
|
+
# the wrong dimensions
|
|
140
|
+
if A.sy.rows != b.sy.rows:
|
|
141
|
+
raise PycodemathError(
|
|
142
|
+
f"solve_system: incompatible dimensions — A has {A.sy.rows} rows, "
|
|
143
|
+
f"but b has {b.sy.rows} elements"
|
|
144
|
+
)
|
|
145
|
+
square = A.sy.rows == A.sy.cols
|
|
146
|
+
if not numeric:
|
|
147
|
+
try:
|
|
148
|
+
if square:
|
|
149
|
+
return Matrix(A.sy.LUsolve(b.sy))
|
|
150
|
+
return Matrix(A.sy.solve_least_squares(b.sy))
|
|
151
|
+
except _FALLBACK_ERRORS:
|
|
152
|
+
pass
|
|
153
|
+
An = _to_float(A)
|
|
154
|
+
bn = _to_float(b).reshape(-1)
|
|
155
|
+
if square:
|
|
156
|
+
x = _numeric(np.linalg.solve, An, bn)
|
|
157
|
+
else:
|
|
158
|
+
x = _numeric(np.linalg.lstsq, An, bn, rcond=None)[0]
|
|
159
|
+
return Matrix(sp.Matrix(np.asarray(x).reshape(-1, 1)))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- spectrum ----------------------------------------------------------
|
|
163
|
+
def eigenvalues(a, numeric: bool = False) -> list[Expr]:
|
|
164
|
+
"""Eigenvalues — a list of IR scalars (with multiplicities)."""
|
|
165
|
+
A = _mat(a)
|
|
166
|
+
if not numeric:
|
|
167
|
+
try:
|
|
168
|
+
out: list[Expr] = []
|
|
169
|
+
for val, mult in A.sy.eigenvals().items():
|
|
170
|
+
out.extend([Expr(val)] * int(mult))
|
|
171
|
+
return out
|
|
172
|
+
except _FALLBACK_ERRORS:
|
|
173
|
+
pass
|
|
174
|
+
return [_num_to_expr(v) for v in _numeric(np.linalg.eigvals, _to_float(A))]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def eigenvectors(a, numeric: bool = False) -> list[tuple[Expr, Matrix]]:
|
|
178
|
+
"""Eigenvectors — a list of pairs ``(eigenvalue, vector)``.
|
|
179
|
+
|
|
180
|
+
The symbolic path returns exact vectors (one per eigen-direction); the
|
|
181
|
+
numeric fallback — one normalized vector per value.
|
|
182
|
+
"""
|
|
183
|
+
A = _mat(a)
|
|
184
|
+
if not numeric:
|
|
185
|
+
try:
|
|
186
|
+
out: list[tuple[Expr, Matrix]] = []
|
|
187
|
+
for val, _mult, vecs in A.sy.eigenvects():
|
|
188
|
+
for vec in vecs:
|
|
189
|
+
out.append((Expr(val), Matrix(vec)))
|
|
190
|
+
return out
|
|
191
|
+
except _FALLBACK_ERRORS:
|
|
192
|
+
pass
|
|
193
|
+
w, vecs = _numeric(np.linalg.eig, _to_float(A))
|
|
194
|
+
return [
|
|
195
|
+
(_num_to_expr(w[i]), Matrix(sp.Matrix(vecs[:, i].reshape(-1, 1))))
|
|
196
|
+
for i in range(len(w))
|
|
197
|
+
]
|