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
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Emission of readable Python/NumPy code from IR (with subexpression elimination).
|
|
2
|
+
|
|
3
|
+
This is where "better code than naive Python" is born: ``sympy.cse`` catches
|
|
4
|
+
repeated subexpressions and computes them once, into helper variables.
|
|
5
|
+
The result is ready-to-use function source + its argument names.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import sympy as sp
|
|
13
|
+
from sympy.printing.numpy import NumPyPrinter
|
|
14
|
+
|
|
15
|
+
from ..core.errors import PycodemathError
|
|
16
|
+
from ..core.ir import Expr, Matrix
|
|
17
|
+
from ..engine.numerics import _DOMAIN_ERRORS
|
|
18
|
+
from ..engine.ode import _DP_A, _DP_B5, _DP_C, _DP_E, _DP_P
|
|
19
|
+
|
|
20
|
+
_printer = NumPyPrinter()
|
|
21
|
+
|
|
22
|
+
#: Tuple of domain exceptions in the EMITTED code — printed from the same
|
|
23
|
+
#: constant as the engine (``numerics._DOMAIN_ERRORS``), so that
|
|
24
|
+
#: codegen<->engine parity does not drift apart with future changes.
|
|
25
|
+
_DOMAIN_ERRORS_SRC = "(" + ", ".join(e.__name__ for e in _DOMAIN_ERRORS) + ")"
|
|
26
|
+
|
|
27
|
+
# Header making the emitted source standalone (runnable after copying).
|
|
28
|
+
IMPORT_HEADER = "import numpy as np"
|
|
29
|
+
|
|
30
|
+
#: Names that a symbol/variable must NOT carry: they would become a parameter
|
|
31
|
+
#: of the emitted function and shadow the NumPy alias inside it.
|
|
32
|
+
_RESERVED_NAMES = {"np", "numpy"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_names(names, what: str) -> None:
|
|
36
|
+
# non-identifiers of Python (e.g. func_name from the direct API) injected
|
|
37
|
+
# into the emitted source would break the syntax or open an injection into exec —
|
|
38
|
+
# we reject them before they reach compile()
|
|
39
|
+
invalid = sorted(n for n in names if not str(n).isidentifier())
|
|
40
|
+
if invalid:
|
|
41
|
+
raise PycodemathError(
|
|
42
|
+
f"{what}: name {', '.join(map(repr, invalid))} is not a valid "
|
|
43
|
+
f"Python identifier — use letters/digits/underscores"
|
|
44
|
+
)
|
|
45
|
+
bad = sorted(_RESERVED_NAMES & set(names))
|
|
46
|
+
if bad:
|
|
47
|
+
raise PycodemathError(
|
|
48
|
+
f"{what}: name {', '.join(bad)} collides with the NumPy alias "
|
|
49
|
+
f"in the generated code — rename the symbol"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_ode_params(var: str, func: str, func_name: str, what: str) -> None:
|
|
54
|
+
"""Name validation for ODE emitters: valid identifiers + ``var != func``.
|
|
55
|
+
|
|
56
|
+
Emitters generate ``def _rhs(var, func):`` — when ``var == func``, it would produce
|
|
57
|
+
``def _rhs(y, y):`` (duplicate argument = ``SyntaxError`` when running
|
|
58
|
+
the artifact). The engine (``engine.ode``) already blocks this; codegen must do the same,
|
|
59
|
+
so that the contract "always ``PycodemathError``" holds on both paths.
|
|
60
|
+
"""
|
|
61
|
+
_check_names([var, func, func_name], what)
|
|
62
|
+
if var == func:
|
|
63
|
+
raise PycodemathError(
|
|
64
|
+
f"{what}: the name of the independent variable and the function must differ "
|
|
65
|
+
f"(both are {var!r}) — otherwise '_rhs({var}, {func})' has a duplicate argument"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pycode(sy: sp.Expr) -> str:
|
|
70
|
+
# NumPyPrinter prints calls as ``numpy.sin(...)``; we normalize to
|
|
71
|
+
# ``np.`` — consistent with the header ``import numpy as np``.
|
|
72
|
+
return _printer.doprint(sy).replace("numpy.", "np.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class GeneratedCode:
|
|
77
|
+
"""Generator result: standalone function source + metadata."""
|
|
78
|
+
|
|
79
|
+
source: str
|
|
80
|
+
func_name: str
|
|
81
|
+
arg_names: list[str]
|
|
82
|
+
header: str = IMPORT_HEADER
|
|
83
|
+
|
|
84
|
+
def __str__(self) -> str: # convenient preview in the REPL
|
|
85
|
+
return self.source
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _assemble(header: str, body_lines: list[str]) -> str:
|
|
89
|
+
"""Assemble the complete, runnable source: import header + function body."""
|
|
90
|
+
return header + "\n\n\n" + "\n".join(body_lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def emit_function(
|
|
94
|
+
expr: Expr,
|
|
95
|
+
func_name: str = "f",
|
|
96
|
+
use_cse: bool = True,
|
|
97
|
+
) -> GeneratedCode:
|
|
98
|
+
"""Build the source of a Python/NumPy function computing ``expr``.
|
|
99
|
+
|
|
100
|
+
When ``use_cse`` — repeated subexpressions go into variables ``_c0``,
|
|
101
|
+
``_c1``… and are computed once (faster code than a naive expansion).
|
|
102
|
+
"""
|
|
103
|
+
arg_names = expr.symbol_names()
|
|
104
|
+
_check_names([*arg_names, func_name], "emit_function")
|
|
105
|
+
signature = f"def {func_name}({', '.join(arg_names)}):"
|
|
106
|
+
docstring = f' """Pycodemath: {func_name}({", ".join(arg_names)}) — NumPy code."""'
|
|
107
|
+
|
|
108
|
+
lines: list[str] = [signature, docstring]
|
|
109
|
+
if use_cse:
|
|
110
|
+
replacements, reduced = sp.cse(expr.sy, symbols=sp.numbered_symbols("_c"))
|
|
111
|
+
for tmp, sub in replacements:
|
|
112
|
+
lines.append(f" {tmp} = {_pycode(sub)}")
|
|
113
|
+
result = reduced[0]
|
|
114
|
+
else:
|
|
115
|
+
result = expr.sy
|
|
116
|
+
lines.append(f" return {_pycode(result)}")
|
|
117
|
+
|
|
118
|
+
return GeneratedCode(
|
|
119
|
+
source=_assemble(IMPORT_HEADER, lines),
|
|
120
|
+
func_name=func_name,
|
|
121
|
+
arg_names=arg_names,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def emit_ode(
|
|
126
|
+
rhs: Expr,
|
|
127
|
+
var: str = "t",
|
|
128
|
+
func: str = "y",
|
|
129
|
+
func_name: str = "solve_ode",
|
|
130
|
+
use_cse: bool = True,
|
|
131
|
+
) -> GeneratedCode:
|
|
132
|
+
"""Build the source of a standalone RK4 integrator for ``y'(t) = f(t, y)``.
|
|
133
|
+
|
|
134
|
+
``rhs`` is the right-hand side (scalar ODE; emission of systems out of scope),
|
|
135
|
+
depending solely on ``var`` and ``func``. CSE acts on the right-hand side —
|
|
136
|
+
repeated subexpressions are computed once per ``_rhs`` call. The emitted
|
|
137
|
+
function takes RUNTIME arguments ``(y0, t0, t1, n)`` — and those are
|
|
138
|
+
in ``arg_names`` — and returns samples ``(ts, ys)``: exactly the same result
|
|
139
|
+
as ``engine.ode.solve_ode_num``.
|
|
140
|
+
"""
|
|
141
|
+
rhs = Expr(rhs)
|
|
142
|
+
_check_ode_params(var, func, func_name, "emit_ode")
|
|
143
|
+
extra = sorted(set(rhs.symbol_names()) - {var, func})
|
|
144
|
+
if extra:
|
|
145
|
+
raise PycodemathError(
|
|
146
|
+
f"emit_ode: the right-hand side also depends on {', '.join(extra)} — "
|
|
147
|
+
f"substitute parameter values before generation"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if use_cse:
|
|
151
|
+
replacements, reduced = sp.cse(rhs.sy, symbols=sp.numbered_symbols("_c"))
|
|
152
|
+
result = reduced[0]
|
|
153
|
+
else:
|
|
154
|
+
replacements, result = [], rhs.sy
|
|
155
|
+
|
|
156
|
+
lines: list[str] = [
|
|
157
|
+
f"def {func_name}(y0, t0, t1, n):",
|
|
158
|
+
f' """Pycodemath: {func_name}(y0, t0, t1, n) '
|
|
159
|
+
f"— RK4 for {func}'({var}) = f({var}, {func}).\"\"\"",
|
|
160
|
+
# _rhs parameters are literally the symbol names — the CSE printout matches
|
|
161
|
+
f" def _rhs({var}, {func}):",
|
|
162
|
+
]
|
|
163
|
+
for tmp, sub in replacements:
|
|
164
|
+
lines.append(f" {tmp} = {_pycode(sub)}")
|
|
165
|
+
lines += [
|
|
166
|
+
f" return {_pycode(result)}",
|
|
167
|
+
" h = (t1 - t0) / n",
|
|
168
|
+
" t = t0",
|
|
169
|
+
" y = y0",
|
|
170
|
+
" ts = [t]",
|
|
171
|
+
" ys = [y]",
|
|
172
|
+
" for _i in range(n):",
|
|
173
|
+
# consistent with the engine: divergence/out of domain -> readable error,
|
|
174
|
+
# never inf/NaN in the result (ASCII messages — the artifact is sometimes run
|
|
175
|
+
# on cp1252 consoles)
|
|
176
|
+
" try:",
|
|
177
|
+
" k1 = _rhs(t, y)",
|
|
178
|
+
" k2 = _rhs(t + h / 2, y + h / 2 * k1)",
|
|
179
|
+
" k3 = _rhs(t + h / 2, y + h / 2 * k2)",
|
|
180
|
+
" k4 = _rhs(t + h, y + h * k3)",
|
|
181
|
+
# IEEE order identical to the engine (_rk4): h/6.0 * (...) — bit-exact parity
|
|
182
|
+
" y = y + h / 6.0 * (k1 + 2 * k2 + 2 * k3 + k4)",
|
|
183
|
+
f" except {_DOMAIN_ERRORS_SRC} as exc:",
|
|
184
|
+
" raise ValueError(",
|
|
185
|
+
f" f'{func_name}: cannot compute step at "
|
|
186
|
+
"t={t:g} ({exc})') from exc",
|
|
187
|
+
" t = t0 + (_i + 1) * h",
|
|
188
|
+
" if not np.isfinite(y):",
|
|
189
|
+
" raise ValueError(",
|
|
190
|
+
f" f'{func_name}: solution diverges at "
|
|
191
|
+
"t={t:g} (NaN/inf) - narrow the interval or increase n')",
|
|
192
|
+
" ts.append(t)",
|
|
193
|
+
" ys.append(y)",
|
|
194
|
+
" return ts, ys",
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
return GeneratedCode(
|
|
198
|
+
source=_assemble(IMPORT_HEADER, lines),
|
|
199
|
+
func_name=func_name,
|
|
200
|
+
arg_names=["y0", "t0", "t1", "n"],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _floats(seq) -> str:
|
|
205
|
+
"""Print a tuple of floats with full precision (``repr`` round-trips)."""
|
|
206
|
+
vals = ", ".join(repr(float(v)) for v in seq)
|
|
207
|
+
return f"({vals},)" if len(seq) == 1 else f"({vals})"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def emit_ode_adaptive(
|
|
211
|
+
rhs: Expr,
|
|
212
|
+
var: str = "t",
|
|
213
|
+
func: str = "y",
|
|
214
|
+
func_name: str = "solve_ode_adaptive",
|
|
215
|
+
use_cse: bool = True,
|
|
216
|
+
) -> GeneratedCode:
|
|
217
|
+
"""Build the source of a standalone ADAPTIVE integrator (DOPRI5).
|
|
218
|
+
|
|
219
|
+
The emitted solver mirrors the engine ``engine.ode.solve_ode_adaptive``:
|
|
220
|
+
a Dormand-Prince 5(4) pair with FSAL, step control with weight
|
|
221
|
+
``atol + rtol·|y|`` (safety factor 0.9, exponent 1/5, growth ≤5×),
|
|
222
|
+
BACKWARD integration for ``t1 < t0`` (step sign), refusal at a
|
|
223
|
+
singularity. The coefficients and error formula are LITERALLY the same as
|
|
224
|
+
in the engine (import of ``_DP_*`` constants) — node-for-node parity.
|
|
225
|
+
|
|
226
|
+
The function takes ``(y0, t0, t1, rtol=1e-6, atol=1e-9,
|
|
227
|
+
max_steps=100000)`` and returns samples ``(ts, ys)``. Divergence /
|
|
228
|
+
singularity -> plain ``ValueError`` (ASCII messages — the artifact
|
|
229
|
+
is sometimes run on cp1252 consoles). Deliberately out of scope:
|
|
230
|
+
dense output and events in codegen.
|
|
231
|
+
"""
|
|
232
|
+
rhs = Expr(rhs)
|
|
233
|
+
_check_ode_params(var, func, func_name, "emit_ode_adaptive")
|
|
234
|
+
extra = sorted(set(rhs.symbol_names()) - {var, func})
|
|
235
|
+
if extra:
|
|
236
|
+
raise PycodemathError(
|
|
237
|
+
f"emit_ode_adaptive: the right-hand side also depends on {', '.join(extra)} — "
|
|
238
|
+
f"substitute parameter values before generation"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if use_cse:
|
|
242
|
+
replacements, reduced = sp.cse(rhs.sy, symbols=sp.numbered_symbols("_c"))
|
|
243
|
+
result = reduced[0]
|
|
244
|
+
else:
|
|
245
|
+
replacements, result = [], rhs.sy
|
|
246
|
+
|
|
247
|
+
lines: list[str] = [
|
|
248
|
+
f"def {func_name}(y0, t0, t1, rtol=1e-6, atol=1e-9, max_steps=100000):",
|
|
249
|
+
f' """Pycodemath: {func_name}(y0, t0, t1, rtol, atol, max_steps) '
|
|
250
|
+
f"— DOPRI5 for {func}'({var}) = f({var}, {func}).\"\"\"",
|
|
251
|
+
f" def _rhs({var}, {func}):",
|
|
252
|
+
]
|
|
253
|
+
for tmp, sub in replacements:
|
|
254
|
+
lines.append(f" {tmp} = {_pycode(sub)}")
|
|
255
|
+
lines += [
|
|
256
|
+
f" return {_pycode(result)}",
|
|
257
|
+
# DOPRI5 coefficients — bit-identical to the engine constants
|
|
258
|
+
f" _C = {_floats(_DP_C)}",
|
|
259
|
+
" _A = (" + ", ".join(_floats(row) for row in _DP_A) + ")",
|
|
260
|
+
f" _B5 = {_floats(_DP_B5)}",
|
|
261
|
+
f" _E = {_floats(_DP_E)}",
|
|
262
|
+
" if t1 == t0:",
|
|
263
|
+
f" raise ValueError('{func_name}: interval cannot have zero length')",
|
|
264
|
+
" if not (rtol > 0.0 and atol > 0.0):",
|
|
265
|
+
f" raise ValueError('{func_name}: rtol and atol must be positive')",
|
|
266
|
+
" hmin = 1e-12 * max(1.0, abs(t0), abs(t1))",
|
|
267
|
+
" sgn = 1.0 if t1 > t0 else -1.0",
|
|
268
|
+
" h = (t1 - t0) / 100.0",
|
|
269
|
+
" t = t0",
|
|
270
|
+
" y = y0",
|
|
271
|
+
" ts = [t]",
|
|
272
|
+
" ys = [y]",
|
|
273
|
+
" steps = 0",
|
|
274
|
+
" try:",
|
|
275
|
+
" k1 = _rhs(t, y)",
|
|
276
|
+
" if not np.isfinite(k1):",
|
|
277
|
+
" raise ValueError('start out of domain')",
|
|
278
|
+
f" except {_DOMAIN_ERRORS_SRC} as exc:",
|
|
279
|
+
" raise ValueError(",
|
|
280
|
+
f" f'{func_name}: cannot sample the right-hand side at the starting "
|
|
281
|
+
"point t={t:g}') from exc",
|
|
282
|
+
" while (t1 - t) * sgn > 0:",
|
|
283
|
+
" if steps >= max_steps:",
|
|
284
|
+
" raise ValueError(",
|
|
285
|
+
f" f'{func_name}: exceeded " + "{max_steps} steps "
|
|
286
|
+
"at t={t:g} - solution is probably singular')",
|
|
287
|
+
" last = (t + h - t1) * sgn >= 0",
|
|
288
|
+
" if last:",
|
|
289
|
+
" h = t1 - t",
|
|
290
|
+
" try:",
|
|
291
|
+
" ks = [k1]",
|
|
292
|
+
" for _ci, _ai in zip(_C, _A):",
|
|
293
|
+
" _yi = y + h * sum(_ai[_j] * ks[_j] for _j in range(len(_ai)))",
|
|
294
|
+
" ks.append(_rhs(t + _ci * h, _yi))",
|
|
295
|
+
" y5 = y + h * sum(_B5[_i] * ks[_i] for _i in range(7))",
|
|
296
|
+
" err = h * sum(_E[_i] * ks[_i] for _i in range(7))",
|
|
297
|
+
" if not (np.isfinite(y5) and np.isfinite(err)):",
|
|
298
|
+
" raise ValueError('step out of domain')",
|
|
299
|
+
f" except {_DOMAIN_ERRORS_SRC}:",
|
|
300
|
+
" h *= 0.5",
|
|
301
|
+
" if abs(h) < hmin:",
|
|
302
|
+
" raise ValueError(",
|
|
303
|
+
f" f'{func_name}: cannot sample the right-hand side "
|
|
304
|
+
"at t={t:g} (singularity or out of domain)')",
|
|
305
|
+
" continue",
|
|
306
|
+
" sc = atol + rtol * np.maximum(np.abs(y), np.abs(y5))",
|
|
307
|
+
" e = float(np.sqrt(np.mean((err / sc) ** 2)))",
|
|
308
|
+
" if e <= 1.0:",
|
|
309
|
+
" t = t1 if last else t + h",
|
|
310
|
+
" y = y5",
|
|
311
|
+
" k1 = ks[6]",
|
|
312
|
+
" ts.append(t)",
|
|
313
|
+
" ys.append(y)",
|
|
314
|
+
" steps += 1",
|
|
315
|
+
" grow = 5.0 if e == 0.0 else 0.9 * e ** -0.2",
|
|
316
|
+
" h *= min(5.0, grow)",
|
|
317
|
+
" else:",
|
|
318
|
+
" h *= max(0.2, 0.9 * e ** -0.2)",
|
|
319
|
+
" if abs(h) < hmin:",
|
|
320
|
+
" raise ValueError(",
|
|
321
|
+
f" f'{func_name}: step shrank below the limit "
|
|
322
|
+
"at t={t:g} - singularity (integration refuses)')",
|
|
323
|
+
" return ts, ys",
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
return GeneratedCode(
|
|
327
|
+
source=_assemble(IMPORT_HEADER, lines),
|
|
328
|
+
func_name=func_name,
|
|
329
|
+
arg_names=["y0", "t0", "t1", "rtol", "atol", "max_steps"],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def emit_ode_dense(
|
|
334
|
+
rhs: Expr,
|
|
335
|
+
var: str = "t",
|
|
336
|
+
func: str = "y",
|
|
337
|
+
func_name: str = "solve_ode_dense",
|
|
338
|
+
use_cse: bool = True,
|
|
339
|
+
) -> GeneratedCode:
|
|
340
|
+
"""Build the source of a standalone solver with DENSE OUTPUT (DOPRI5).
|
|
341
|
+
|
|
342
|
+
The emitted solver integrates like ``emit_ode_adaptive`` (the same loop,
|
|
343
|
+
coefficients and error formula — bit-exact parity with the engine), but on
|
|
344
|
+
each accepted step it stores a segment ``(t_old, h, y_old, ks)``
|
|
345
|
+
and instead of samples alone returns an INTERPOLANT FUNCTION ``sol``:
|
|
346
|
+
``sol(t)`` reads ``y(t)`` at ANY point of the interval through the
|
|
347
|
+
continuous 4th-order DOPRI5 extension (formula from ``engine._seg_eval``,
|
|
348
|
+
coefficients ``_DP_P`` imported from the engine constants and printed
|
|
349
|
+
with ``repr`` — a single source of truth). Nodes available as ``sol.ts`` /
|
|
350
|
+
``sol.ys``; scalar -> ``float``, sequence -> ``np.ndarray``;
|
|
351
|
+
``t`` out of the interval / singularity -> plain ``ValueError``
|
|
352
|
+
(ASCII messages — the artifact is sometimes run on cp1252 consoles).
|
|
353
|
+
|
|
354
|
+
The function takes ``(y0, t0, t1, rtol=1e-6, atol=1e-9,
|
|
355
|
+
max_steps=100000)``. Deliberately out of scope: events in codegen.
|
|
356
|
+
"""
|
|
357
|
+
rhs = Expr(rhs)
|
|
358
|
+
_check_ode_params(var, func, func_name, "emit_ode_dense")
|
|
359
|
+
extra = sorted(set(rhs.symbol_names()) - {var, func})
|
|
360
|
+
if extra:
|
|
361
|
+
raise PycodemathError(
|
|
362
|
+
f"emit_ode_dense: the right-hand side also depends on {', '.join(extra)} — "
|
|
363
|
+
f"substitute parameter values before generation"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if use_cse:
|
|
367
|
+
replacements, reduced = sp.cse(rhs.sy, symbols=sp.numbered_symbols("_c"))
|
|
368
|
+
result = reduced[0]
|
|
369
|
+
else:
|
|
370
|
+
replacements, result = [], rhs.sy
|
|
371
|
+
|
|
372
|
+
lines: list[str] = [
|
|
373
|
+
f"def {func_name}(y0, t0, t1, rtol=1e-6, atol=1e-9, max_steps=100000):",
|
|
374
|
+
f' """Pycodemath: {func_name}(y0, t0, t1, rtol, atol, max_steps) '
|
|
375
|
+
f"— DOPRI5 with dense output for {func}'({var}) = f({var}, {func}); "
|
|
376
|
+
'returns sol(t) (nodes: sol.ts, sol.ys)."""',
|
|
377
|
+
f" def _rhs({var}, {func}):",
|
|
378
|
+
]
|
|
379
|
+
for tmp, sub in replacements:
|
|
380
|
+
lines.append(f" {tmp} = {_pycode(sub)}")
|
|
381
|
+
lines += [
|
|
382
|
+
f" return {_pycode(result)}",
|
|
383
|
+
# DOPRI5 coefficients + interpolant — bit-identical to the engine
|
|
384
|
+
f" _C = {_floats(_DP_C)}",
|
|
385
|
+
" _A = (" + ", ".join(_floats(row) for row in _DP_A) + ")",
|
|
386
|
+
f" _B5 = {_floats(_DP_B5)}",
|
|
387
|
+
f" _E = {_floats(_DP_E)}",
|
|
388
|
+
" _P = np.array((" + ", ".join(_floats(row) for row in _DP_P) + "))",
|
|
389
|
+
" if t1 == t0:",
|
|
390
|
+
f" raise ValueError('{func_name}: interval cannot have zero length')",
|
|
391
|
+
" if not (rtol > 0.0 and atol > 0.0):",
|
|
392
|
+
f" raise ValueError('{func_name}: rtol and atol must be positive')",
|
|
393
|
+
" hmin = 1e-12 * max(1.0, abs(t0), abs(t1))",
|
|
394
|
+
" sgn = 1.0 if t1 > t0 else -1.0",
|
|
395
|
+
" h = (t1 - t0) / 100.0",
|
|
396
|
+
" t = t0",
|
|
397
|
+
" y = y0",
|
|
398
|
+
" ts = [t]",
|
|
399
|
+
" ys = [y]",
|
|
400
|
+
" segs = []",
|
|
401
|
+
" steps = 0",
|
|
402
|
+
" try:",
|
|
403
|
+
" k1 = _rhs(t, y)",
|
|
404
|
+
" if not np.isfinite(k1):",
|
|
405
|
+
" raise ValueError('start out of domain')",
|
|
406
|
+
f" except {_DOMAIN_ERRORS_SRC} as exc:",
|
|
407
|
+
" raise ValueError(",
|
|
408
|
+
f" f'{func_name}: cannot sample the right-hand side at the starting "
|
|
409
|
+
"point t={t:g}') from exc",
|
|
410
|
+
" while (t1 - t) * sgn > 0:",
|
|
411
|
+
" if steps >= max_steps:",
|
|
412
|
+
" raise ValueError(",
|
|
413
|
+
f" f'{func_name}: exceeded " + "{max_steps} steps "
|
|
414
|
+
"at t={t:g} - solution is probably singular')",
|
|
415
|
+
" last = (t + h - t1) * sgn >= 0",
|
|
416
|
+
" if last:",
|
|
417
|
+
" h = t1 - t",
|
|
418
|
+
" try:",
|
|
419
|
+
" ks = [k1]",
|
|
420
|
+
" for _ci, _ai in zip(_C, _A):",
|
|
421
|
+
" _yi = y + h * sum(_ai[_j] * ks[_j] for _j in range(len(_ai)))",
|
|
422
|
+
" ks.append(_rhs(t + _ci * h, _yi))",
|
|
423
|
+
" y5 = y + h * sum(_B5[_i] * ks[_i] for _i in range(7))",
|
|
424
|
+
" err = h * sum(_E[_i] * ks[_i] for _i in range(7))",
|
|
425
|
+
" if not (np.isfinite(y5) and np.isfinite(err)):",
|
|
426
|
+
" raise ValueError('step out of domain')",
|
|
427
|
+
f" except {_DOMAIN_ERRORS_SRC}:",
|
|
428
|
+
" h *= 0.5",
|
|
429
|
+
" if abs(h) < hmin:",
|
|
430
|
+
" raise ValueError(",
|
|
431
|
+
f" f'{func_name}: cannot sample the right-hand side "
|
|
432
|
+
"at t={t:g} (singularity or out of domain)')",
|
|
433
|
+
" continue",
|
|
434
|
+
" sc = atol + rtol * np.maximum(np.abs(y), np.abs(y5))",
|
|
435
|
+
" e = float(np.sqrt(np.mean((err / sc) ** 2)))",
|
|
436
|
+
" if e <= 1.0:",
|
|
437
|
+
" segs.append((t, h, y, ks))", # before update: t_old, y_old
|
|
438
|
+
" t = t1 if last else t + h",
|
|
439
|
+
" y = y5",
|
|
440
|
+
" k1 = ks[6]",
|
|
441
|
+
" ts.append(t)",
|
|
442
|
+
" ys.append(y)",
|
|
443
|
+
" steps += 1",
|
|
444
|
+
" grow = 5.0 if e == 0.0 else 0.9 * e ** -0.2",
|
|
445
|
+
" h *= min(5.0, grow)",
|
|
446
|
+
" else:",
|
|
447
|
+
" h *= max(0.2, 0.9 * e ** -0.2)",
|
|
448
|
+
" if abs(h) < hmin:",
|
|
449
|
+
" raise ValueError(",
|
|
450
|
+
f" f'{func_name}: step shrank below the limit "
|
|
451
|
+
"at t={t:g} - singularity (integration refuses)')",
|
|
452
|
+
# dense output: bisection over segment starts ALONG the run
|
|
453
|
+
# (multiplication by sgn brings them to ascending order — backward also
|
|
454
|
+
# works), read via the continuous 4th-order extension (formula _seg_eval)
|
|
455
|
+
" _starts = np.array([sgn * _s[0] for _s in segs])",
|
|
456
|
+
" _lo = t0 if t0 < t1 else t1",
|
|
457
|
+
" _hi = t1 if t0 < t1 else t0",
|
|
458
|
+
" _eps = 1e-9 * max(1.0, abs(_lo), abs(_hi))",
|
|
459
|
+
" def _eval_one(_tq):",
|
|
460
|
+
" if _tq < _lo - _eps or _tq > _hi + _eps:",
|
|
461
|
+
" raise ValueError(",
|
|
462
|
+
f" f'{func_name}: t=" + "{_tq:g} outside the interval "
|
|
463
|
+
"[{t0:g}, {t1:g}]')",
|
|
464
|
+
" _idx = int(np.searchsorted(_starts, sgn * _tq, side='right') - 1)",
|
|
465
|
+
" _idx = min(max(_idx, 0), len(segs) - 1)",
|
|
466
|
+
" _to, _h, _yo, _ks = segs[_idx]",
|
|
467
|
+
" _th = (_tq - _to) / _h",
|
|
468
|
+
" _pw = np.array([_th, _th**2, _th**3, _th**4])",
|
|
469
|
+
" _acc = sum(_ks[_i] * float(_P[_i] @ _pw) for _i in range(7))",
|
|
470
|
+
" return _yo + _h * _acc",
|
|
471
|
+
" def _sol(t_query):",
|
|
472
|
+
" if np.ndim(t_query) == 0:",
|
|
473
|
+
" return float(_eval_one(float(t_query)))",
|
|
474
|
+
" return np.array([float(_eval_one(float(_v))) for _v in t_query])",
|
|
475
|
+
" _sol.ts = ts",
|
|
476
|
+
" _sol.ys = ys",
|
|
477
|
+
" return _sol",
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
return GeneratedCode(
|
|
481
|
+
source=_assemble(IMPORT_HEADER, lines),
|
|
482
|
+
func_name=func_name,
|
|
483
|
+
arg_names=["y0", "t0", "t1", "rtol", "atol", "max_steps"],
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def emit_solver(
|
|
488
|
+
A,
|
|
489
|
+
b,
|
|
490
|
+
func_name: str = "solve",
|
|
491
|
+
use_cse: bool = True,
|
|
492
|
+
) -> GeneratedCode:
|
|
493
|
+
"""Build the source of a function solving the system ``A x = b`` via NumPy.
|
|
494
|
+
|
|
495
|
+
Instead of expanding a symbolic inverse (costly and numerically
|
|
496
|
+
unstable), we emit code that builds the matrices and calls
|
|
497
|
+
``numpy.linalg.solve`` — this is the "optimized NumPy code".
|
|
498
|
+
When ``A``/``b`` have parameters, they become arguments of the function;
|
|
499
|
+
repeated matrix entries are caught by CSE.
|
|
500
|
+
"""
|
|
501
|
+
A = Matrix(A)
|
|
502
|
+
b = Matrix(b)
|
|
503
|
+
n, m = A.sy.rows, A.sy.cols
|
|
504
|
+
|
|
505
|
+
arg_names = sorted({s.name for s in A.sy.free_symbols} | {s.name for s in b.sy.free_symbols})
|
|
506
|
+
_check_names([*arg_names, func_name], "emit_solver")
|
|
507
|
+
entries = list(A.sy) + list(b.sy) # row-major: n*m entries of A, then n entries of b
|
|
508
|
+
|
|
509
|
+
# dtype (DECISION — Phase 3): three cases, because SymPy knows realness as
|
|
510
|
+
# True/False/None. Explicitly complex entry → ``complex``. All explicitly
|
|
511
|
+
# real → ``float`` (readable, exact). UNKNOWN realness (``is_real
|
|
512
|
+
# is None`` — e.g. a bare ``Symbol('a')``) → we do NOT force dtype: if a
|
|
513
|
+
# runtime argument made the entry complex, ``dtype=float`` would silently cut off
|
|
514
|
+
# the imaginary part (garbage result — breaking the "zero garbage" contract). Without
|
|
515
|
+
# dtype NumPy infers from the values: real input stays ``float64``,
|
|
516
|
+
# complex gives correct ``complex128``.
|
|
517
|
+
if any(e.is_real is False for e in entries):
|
|
518
|
+
dtype = "complex"
|
|
519
|
+
elif entries and all(e.is_real for e in entries): # None is falsy → here only pure True
|
|
520
|
+
dtype = "float" # empty list does NOT force float (all([]) == True)
|
|
521
|
+
else:
|
|
522
|
+
dtype = None
|
|
523
|
+
dtype_kw = "" if dtype is None else f", dtype={dtype}"
|
|
524
|
+
|
|
525
|
+
if use_cse:
|
|
526
|
+
replacements, reduced = sp.cse(entries, symbols=sp.numbered_symbols("_c"))
|
|
527
|
+
else:
|
|
528
|
+
replacements, reduced = [], entries
|
|
529
|
+
a_red, b_red = reduced[: n * m], reduced[n * m :]
|
|
530
|
+
|
|
531
|
+
signature = f"def {func_name}({', '.join(arg_names)}):"
|
|
532
|
+
docstring = (
|
|
533
|
+
f' """Pycodemath: {func_name}({", ".join(arg_names)}) '
|
|
534
|
+
f'— solves A x = b via NumPy."""'
|
|
535
|
+
)
|
|
536
|
+
lines: list[str] = [signature, docstring]
|
|
537
|
+
for tmp, sub in replacements:
|
|
538
|
+
lines.append(f" {tmp} = {_pycode(sub)}")
|
|
539
|
+
|
|
540
|
+
matrix_rows = [
|
|
541
|
+
"[" + ", ".join(_pycode(a_red[i * m + j]) for j in range(m)) + "]"
|
|
542
|
+
for i in range(n)
|
|
543
|
+
]
|
|
544
|
+
lines.append(f" _A = np.array([{', '.join(matrix_rows)}]{dtype_kw})")
|
|
545
|
+
lines.append(
|
|
546
|
+
f" _b = np.array([{', '.join(_pycode(e) for e in b_red)}]{dtype_kw})"
|
|
547
|
+
)
|
|
548
|
+
lines.append(" return np.linalg.solve(_A, _b)")
|
|
549
|
+
|
|
550
|
+
return GeneratedCode(
|
|
551
|
+
source=_assemble(IMPORT_HEADER, lines),
|
|
552
|
+
func_name=func_name,
|
|
553
|
+
arg_names=arg_names,
|
|
554
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Code generator pipeline (module 2): IR → simplify → CSE → code + callable.
|
|
2
|
+
|
|
3
|
+
Ties source emission (``emit``) to compilation into a live function.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..core.ir import Expr
|
|
14
|
+
from .emit import (
|
|
15
|
+
GeneratedCode,
|
|
16
|
+
emit_function,
|
|
17
|
+
emit_ode,
|
|
18
|
+
emit_ode_adaptive,
|
|
19
|
+
emit_ode_dense,
|
|
20
|
+
emit_solver,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CodeArtifact:
|
|
26
|
+
"""Full generation result: source, live function and metadata."""
|
|
27
|
+
|
|
28
|
+
code: GeneratedCode
|
|
29
|
+
func: Callable
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def source(self) -> str:
|
|
33
|
+
return self.code.source
|
|
34
|
+
|
|
35
|
+
def __call__(self, *args, **kwargs):
|
|
36
|
+
return self.func(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generate(
|
|
40
|
+
expr: Expr,
|
|
41
|
+
func_name: str = "f",
|
|
42
|
+
simplify: bool = True,
|
|
43
|
+
use_cse: bool = True,
|
|
44
|
+
) -> CodeArtifact:
|
|
45
|
+
"""Generate an optimized Python/NumPy function from an IR expression.
|
|
46
|
+
|
|
47
|
+
Steps: (optional) simplify → CSE → source emission → compilation.
|
|
48
|
+
"""
|
|
49
|
+
work = expr.simplify() if simplify else expr
|
|
50
|
+
generated = emit_function(work, func_name=func_name, use_cse=use_cse)
|
|
51
|
+
return CodeArtifact(code=generated, func=_compile(generated))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_system(
|
|
55
|
+
A,
|
|
56
|
+
b,
|
|
57
|
+
func_name: str = "solve",
|
|
58
|
+
use_cse: bool = True,
|
|
59
|
+
) -> CodeArtifact:
|
|
60
|
+
"""Generate an optimized NumPy function solving the system ``A x = b``.
|
|
61
|
+
|
|
62
|
+
Steps: solver emission (CSE over the matrix entries) → compilation. The
|
|
63
|
+
returned function takes the system parameters and returns the solution vector.
|
|
64
|
+
"""
|
|
65
|
+
generated = emit_solver(A, b, func_name=func_name, use_cse=use_cse)
|
|
66
|
+
return CodeArtifact(code=generated, func=_compile(generated))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def generate_ode(
|
|
70
|
+
rhs: Expr,
|
|
71
|
+
var: str = "t",
|
|
72
|
+
func: str = "y",
|
|
73
|
+
func_name: str = "solve_ode",
|
|
74
|
+
use_cse: bool = True,
|
|
75
|
+
) -> CodeArtifact:
|
|
76
|
+
"""Generate a standalone RK4 integrator (Python/NumPy) for ``y' = f(t, y)``.
|
|
77
|
+
|
|
78
|
+
Steps: integrator emission (CSE over the right-hand side) → compilation. The
|
|
79
|
+
returned function takes ``(y0, t0, t1, n)`` and returns samples ``(ts, ys)``
|
|
80
|
+
— the same as ``engine.ode.solve_ode_num``.
|
|
81
|
+
"""
|
|
82
|
+
generated = emit_ode(
|
|
83
|
+
Expr(rhs), var=var, func=func, func_name=func_name, use_cse=use_cse
|
|
84
|
+
)
|
|
85
|
+
return CodeArtifact(code=generated, func=_compile(generated))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate_ode_adaptive(
|
|
89
|
+
rhs: Expr,
|
|
90
|
+
var: str = "t",
|
|
91
|
+
func: str = "y",
|
|
92
|
+
func_name: str = "solve_ode_adaptive",
|
|
93
|
+
use_cse: bool = True,
|
|
94
|
+
) -> CodeArtifact:
|
|
95
|
+
"""Generate a standalone ADAPTIVE DOPRI5 integrator for ``y' = f(t, y)``.
|
|
96
|
+
|
|
97
|
+
Steps: integrator emission (CSE over the right-hand side) → compilation. The
|
|
98
|
+
returned function takes ``(y0, t0, t1, rtol=1e-6, atol=1e-9,
|
|
99
|
+
max_steps=100000)`` and returns samples ``(ts, ys)`` — node parity with
|
|
100
|
+
``engine.ode.solve_ode_adaptive`` (the same coefficients and step control,
|
|
101
|
+
including backward integration).
|
|
102
|
+
"""
|
|
103
|
+
generated = emit_ode_adaptive(
|
|
104
|
+
Expr(rhs), var=var, func=func, func_name=func_name, use_cse=use_cse
|
|
105
|
+
)
|
|
106
|
+
return CodeArtifact(code=generated, func=_compile(generated))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def generate_ode_dense(
|
|
110
|
+
rhs: Expr,
|
|
111
|
+
var: str = "t",
|
|
112
|
+
func: str = "y",
|
|
113
|
+
func_name: str = "solve_ode_dense",
|
|
114
|
+
use_cse: bool = True,
|
|
115
|
+
) -> CodeArtifact:
|
|
116
|
+
"""Generate a standalone solver with DENSE OUTPUT (DOPRI5) for ``y' = f(t, y)``.
|
|
117
|
+
|
|
118
|
+
Steps: integrator emission with an interpolant (CSE over the right-hand
|
|
119
|
+
side) → compilation. The returned function takes ``(y0, t0, t1, rtol=1e-6,
|
|
120
|
+
atol=1e-9, max_steps=100000)`` and returns an INTERPOLANT FUNCTION ``sol`` —
|
|
121
|
+
``sol(t)`` reads ``y(t)`` at any point (bitwise parity of nodes and
|
|
122
|
+
interpolant with ``engine.ode.solve_ode_dense``); nodes: ``sol.ts`` / ``sol.ys``.
|
|
123
|
+
"""
|
|
124
|
+
generated = emit_ode_dense(
|
|
125
|
+
Expr(rhs), var=var, func=func, func_name=func_name, use_cse=use_cse
|
|
126
|
+
)
|
|
127
|
+
return CodeArtifact(code=generated, func=_compile(generated))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _compile(generated: GeneratedCode) -> Callable:
|
|
131
|
+
"""Compile the emitted source into a live function.
|
|
132
|
+
|
|
133
|
+
The emitted code uses the ``np.``/``numpy.`` prefix (and imports NumPy
|
|
134
|
+
itself), so the namespace is limited to the module aliases — without
|
|
135
|
+
injecting hundreds of names from ``dir(np)``.
|
|
136
|
+
"""
|
|
137
|
+
namespace: dict = {"np": np, "numpy": np}
|
|
138
|
+
exec(compile(generated.source, "<pycodemath>", "exec"), namespace)
|
|
139
|
+
return namespace[generated.func_name]
|