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
pycodemath/engine/ode.py
ADDED
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
"""Ordinary differential equations (module 6) — symbolically or via RK4.
|
|
2
|
+
|
|
3
|
+
Operates on IR (``Expr``). The equation is written via the RIGHT-hand side
|
|
4
|
+
``y'(t) = f(t, y)``, in which the unknown function appears as an ordinary
|
|
5
|
+
symbol (``y``) — only ``dsolve`` substitutes ``sympy.Function`` for it.
|
|
6
|
+
When SymPy does not find a closed form (or finds only an implicit one),
|
|
7
|
+
we get a ``PycodemathError`` with a hint to drop down to numerics.
|
|
8
|
+
|
|
9
|
+
Numerics: the classic fixed-step RK4. As throughout the engine, the
|
|
10
|
+
right-hand side is compiled ONCE before the loop (``Expr.compiled`` via
|
|
11
|
+
``_vector_fn``), iterations run without SymPy overhead; ``Expr.evalf`` would
|
|
12
|
+
be orders of magnitude too slow here.
|
|
13
|
+
|
|
14
|
+
Functions:
|
|
15
|
+
dsolve — symbolic solution of an ODE (sympy.dsolve, IR Expr).
|
|
16
|
+
solve_ode_num — initial value problem y' = f(t, y) by RK4 (fixed step).
|
|
17
|
+
solve_ode_adaptive — the same, but the step is chosen automatically
|
|
18
|
+
(Dormand-Prince 5(4), tolerance rtol/atol) — fewer
|
|
19
|
+
steps on a smooth problem, denser sampling near a
|
|
20
|
+
singularity.
|
|
21
|
+
solve_ode_dense — as above + DENSE OUTPUT: returns a callable object
|
|
22
|
+
sol(t) that reads y(t) at ANY point
|
|
23
|
+
(4th-order DOPRI5 interpolant), not only at nodes.
|
|
24
|
+
solve_ode_events — like solve_ode_dense + EVENT DETECTION: the moments
|
|
25
|
+
at which g(t, y) = 0, located by bisection on the
|
|
26
|
+
interpolant (without re-integrating);
|
|
27
|
+
terminal=True STOPS integration at the first
|
|
28
|
+
event (nodes end at t_event).
|
|
29
|
+
solve_ode_stiff — IMPLICIT solver (BDF2 + Newton per step, symbolic
|
|
30
|
+
Jacobian) for STIFF equations, where explicit methods
|
|
31
|
+
are throttled by stability, not accuracy.
|
|
32
|
+
solve_ode_system_stiff — vector counterpart of solve_ode_stiff (module 16):
|
|
33
|
+
BDF2 for stiff SYSTEMS, multidimensional Newton
|
|
34
|
+
per step (matrix I − coef·J, J = ∂F/∂y
|
|
35
|
+
symbolically, np.linalg.solve — the
|
|
36
|
+
root_find_nd pattern).
|
|
37
|
+
solve_ode_stiff_adaptive / solve_ode_system_stiff_adaptive — BDF with a
|
|
38
|
+
VARIABLE step (module 17): variable-step BDF2
|
|
39
|
+
(coefficients depending on the step ratio ω),
|
|
40
|
+
local error estimate from the third divided
|
|
41
|
+
difference, step controller as in DOPRI5 — few
|
|
42
|
+
steps where an explicit method is throttled by
|
|
43
|
+
stability.
|
|
44
|
+
solve_ode_system_num — system y' = F(t, y) (vectorized, RK4).
|
|
45
|
+
solve_ode_system_dense / solve_ode_system_events — vector counterparts of
|
|
46
|
+
dense/events for systems (per-coordinate interpolant,
|
|
47
|
+
event g(t, y1..yn) = 0, also terminal).
|
|
48
|
+
|
|
49
|
+
Divergence / bad domain / no closed form -> ``PycodemathError``
|
|
50
|
+
with a readable message (consistent with ``numerics``).
|
|
51
|
+
|
|
52
|
+
All numerical solvers also accept a DECREASING interval (t1 < t0)
|
|
53
|
+
— BACKWARD integration, step h < 0 (module 13). Beyond scope: PDEs.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
from typing import Callable, Sequence, cast
|
|
59
|
+
|
|
60
|
+
import numpy as np
|
|
61
|
+
import sympy as sp
|
|
62
|
+
|
|
63
|
+
from ..core.errors import PycodemathError
|
|
64
|
+
from ..core.ir import Expr
|
|
65
|
+
from .numerics import _check_vars, _point, _vector_fn
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def dsolve(expr: Expr, func: str = "y", var: str = "t") -> list[Expr]:
|
|
69
|
+
"""Solve the ODE ``func'(var) = expr`` symbolically (sympy.dsolve).
|
|
70
|
+
|
|
71
|
+
``expr`` is the IR ``Expr`` of the right-hand side, with the unknown as an
|
|
72
|
+
ordinary symbol ``func``. Additional symbols (parameters, e.g. ``a`` in
|
|
73
|
+
``a*y``) are allowed — this is the symbolic path. Returns a list of ``Expr``
|
|
74
|
+
— the right-hand sides of EXPLICIT solutions (with constants ``C1``… as
|
|
75
|
+
symbols), consistent with ``symbolic.solve``.
|
|
76
|
+
|
|
77
|
+
No closed form or an implicit solution -> ``PycodemathError``
|
|
78
|
+
with a hint to use ``solve_ode_num``.
|
|
79
|
+
"""
|
|
80
|
+
rhs = Expr(expr)
|
|
81
|
+
if func == var:
|
|
82
|
+
raise PycodemathError(
|
|
83
|
+
"dsolve: function name and independent variable name must differ"
|
|
84
|
+
)
|
|
85
|
+
t = sp.Symbol(var)
|
|
86
|
+
Y = sp.Function(func)
|
|
87
|
+
equation = sp.Eq(Y(t).diff(t), rhs.sy.subs(sp.Symbol(func), Y(t)))
|
|
88
|
+
hint = (
|
|
89
|
+
f"dsolve: cannot find a closed-form solution for "
|
|
90
|
+
f"{func}'({var}) = {rhs} — use solve_ode_num"
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
raw = sp.dsolve(equation, Y(t))
|
|
94
|
+
except (NotImplementedError, ValueError, TypeError) as exc:
|
|
95
|
+
raise PycodemathError(hint) from exc
|
|
96
|
+
|
|
97
|
+
solutions = raw if isinstance(raw, list) else [raw]
|
|
98
|
+
out: list[Expr] = []
|
|
99
|
+
for sol in solutions:
|
|
100
|
+
# explicitness: y(t) = <expression without y(t)> — an implicit solution
|
|
101
|
+
# does not fit the IR Expr contract (an algebraic expression)
|
|
102
|
+
if sol.lhs != Y(t) or sol.rhs.has(sp.core.function.AppliedUndef):
|
|
103
|
+
raise PycodemathError(hint)
|
|
104
|
+
out.append(Expr(sol.rhs))
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- numerical core (shared by scalar and system) ------------------------
|
|
109
|
+
def _span(t_span: "Sequence[float]", what: str) -> tuple[float, float]:
|
|
110
|
+
"""Validate the integration interval; return ``(t0, t1)`` (requires ``t1 ≠ t0``).
|
|
111
|
+
|
|
112
|
+
A DECREASING interval (``t1 < t0``) is allowed — the numerical solvers then
|
|
113
|
+
integrate BACKWARD (step ``h < 0``, module 13).
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
t0, t1 = (float(v) for v in t_span)
|
|
117
|
+
except (TypeError, ValueError) as exc:
|
|
118
|
+
raise PycodemathError(f"{what}: t_span must be a pair of numbers (t0, t1)") from exc
|
|
119
|
+
if t1 == t0:
|
|
120
|
+
raise PycodemathError(
|
|
121
|
+
f"{what}: interval cannot have zero length "
|
|
122
|
+
f"(given {t0:g} .. {t1:g})"
|
|
123
|
+
)
|
|
124
|
+
return t0, t1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _tols(rtol: float, atol: float, what: str) -> tuple[float, float]:
|
|
128
|
+
"""Validate the adaptive solver tolerances; return ``(rtol, atol)``."""
|
|
129
|
+
if not (rtol > 0.0 and atol > 0.0):
|
|
130
|
+
raise PycodemathError(f"{what}: rtol and atol must be positive")
|
|
131
|
+
return float(rtol), float(atol)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _system_inputs(
|
|
135
|
+
rhs: "Sequence[Expr]",
|
|
136
|
+
var: str,
|
|
137
|
+
funcs: "Sequence[str]",
|
|
138
|
+
y0: "Sequence[float]",
|
|
139
|
+
what: str,
|
|
140
|
+
) -> tuple[list[Expr], list[str], np.ndarray]:
|
|
141
|
+
"""Validate the system input ``y' = F(var, y)``; return ``(fs, funcs, y)``.
|
|
142
|
+
|
|
143
|
+
The shared gate of all system solvers: a square system (as many equations
|
|
144
|
+
as functions), expressions depending at most on ``var`` and ``funcs``,
|
|
145
|
+
a start point of the correct length.
|
|
146
|
+
"""
|
|
147
|
+
fs = [Expr(e) for e in rhs]
|
|
148
|
+
funcs = list(funcs)
|
|
149
|
+
if not fs or len(fs) != len(funcs):
|
|
150
|
+
raise PycodemathError(
|
|
151
|
+
f"{what} requires a square system: "
|
|
152
|
+
f"{len(fs)} equations, {len(funcs)} functions"
|
|
153
|
+
)
|
|
154
|
+
_check_vars(fs, [var, *funcs], what)
|
|
155
|
+
y = _point(y0, len(funcs), what)
|
|
156
|
+
return fs, funcs, y
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _grid(t_span: "Sequence[float]", n: int, what: str) -> tuple[float, float, float]:
|
|
160
|
+
"""Validate the interval and the number of steps; return ``(t0, t1, h)``."""
|
|
161
|
+
if int(n) < 1:
|
|
162
|
+
raise PycodemathError(f"{what}: number of steps n must be positive")
|
|
163
|
+
t0, t1 = _span(t_span, what)
|
|
164
|
+
return t0, t1, (t1 - t0) / int(n)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _rk4(
|
|
168
|
+
sample: "Callable[[np.ndarray], np.ndarray | None]",
|
|
169
|
+
t0: float,
|
|
170
|
+
t1: float,
|
|
171
|
+
n: int,
|
|
172
|
+
y: np.ndarray,
|
|
173
|
+
what: str,
|
|
174
|
+
) -> tuple[list[float], list[list[float]]]:
|
|
175
|
+
"""Classic fixed-step RK4 over a COMPILED right-hand side.
|
|
176
|
+
|
|
177
|
+
``sample`` is the closure from ``_vector_fn`` (compilation before the loop),
|
|
178
|
+
called on the vector ``[t, *y]``. Returns ``(ts, rows)``: ``n + 1`` nodes
|
|
179
|
+
including ``t0``, ``rows[k]`` = state at time ``ts[k]``.
|
|
180
|
+
"""
|
|
181
|
+
h = (t1 - t0) / n
|
|
182
|
+
ts = [t0]
|
|
183
|
+
rows = [[float(v) for v in y]]
|
|
184
|
+
t = t0
|
|
185
|
+
for i in range(1, n + 1):
|
|
186
|
+
k1 = sample(np.concatenate(([t], y)))
|
|
187
|
+
k2 = None if k1 is None else sample(np.concatenate(([t + h / 2], y + h / 2 * k1)))
|
|
188
|
+
k3 = None if k2 is None else sample(np.concatenate(([t + h / 2], y + h / 2 * k2)))
|
|
189
|
+
k4 = None if k3 is None else sample(np.concatenate(([t + h], y + h * k3)))
|
|
190
|
+
if k1 is None or k2 is None or k3 is None or k4 is None:
|
|
191
|
+
raise PycodemathError(
|
|
192
|
+
f"{what}: cannot sample the right-hand side at t={t:g} "
|
|
193
|
+
f"(outside the domain, complex result, or divergence)"
|
|
194
|
+
)
|
|
195
|
+
y = y + h / 6.0 * (k1 + 2 * k2 + 2 * k3 + k4)
|
|
196
|
+
t = t0 + i * h # node from multiplication, not summation — no float drift
|
|
197
|
+
if not np.all(np.isfinite(y)):
|
|
198
|
+
raise PycodemathError(
|
|
199
|
+
f"{what}: solution diverges at t={t:g} (NaN/inf) — "
|
|
200
|
+
f"narrow the interval or increase n"
|
|
201
|
+
)
|
|
202
|
+
ts.append(t)
|
|
203
|
+
rows.append([float(v) for v in y])
|
|
204
|
+
return ts, rows
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def solve_ode_num(
|
|
208
|
+
rhs: Expr,
|
|
209
|
+
var: str,
|
|
210
|
+
y0: float,
|
|
211
|
+
t_span: "Sequence[float]",
|
|
212
|
+
n: int = 100,
|
|
213
|
+
func: str = "y",
|
|
214
|
+
) -> tuple[list[float], list[float]]:
|
|
215
|
+
"""Initial value problem ``y'(var) = f(var, y)``, ``y(t0) = y0`` — RK4.
|
|
216
|
+
|
|
217
|
+
``rhs`` depends at most on ``var`` (time) and ``func`` (the unknown).
|
|
218
|
+
Fixed step ``h = (t1 - t0)/n``; returns samples ``(ts, ys)`` — lists
|
|
219
|
+
of length ``n + 1`` with nodes including ``t0``.
|
|
220
|
+
"""
|
|
221
|
+
r = Expr(rhs)
|
|
222
|
+
_check_vars([r], [var, func], "solve_ode_num") # duplicates also catch func == var
|
|
223
|
+
t0, t1, _ = _grid(t_span, n, "solve_ode_num")
|
|
224
|
+
sample = _vector_fn([r], [var, func]) # compiled once — before the loop
|
|
225
|
+
ts, rows = _rk4(
|
|
226
|
+
sample, t0, t1, int(n), np.array([float(y0)], dtype=float), "solve_ode_num"
|
|
227
|
+
)
|
|
228
|
+
return ts, [row[0] for row in rows]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# --- adaptive step (Dormand-Prince 5(4), "ode45") ------------------------
|
|
232
|
+
# Explicit Runge-Kutta pair 5(4): one set of right-hand-side evaluations gives
|
|
233
|
+
# a 5th-order solution AND a built-in 4th-order estimate — their difference
|
|
234
|
+
# drives the step selection. Coefficients after Dormand and Prince (1980), the
|
|
235
|
+
# same as in MATLAB's ode45 / scipy RK45.
|
|
236
|
+
_DP_C = (1 / 5, 3 / 10, 4 / 5, 8 / 9, 1.0, 1.0) # nodes c2..c7 (c1 = 0)
|
|
237
|
+
_DP_A: tuple[tuple[float, ...], ...] = (
|
|
238
|
+
(1 / 5,),
|
|
239
|
+
(3 / 40, 9 / 40),
|
|
240
|
+
(44 / 45, -56 / 15, 32 / 9),
|
|
241
|
+
(19372 / 6561, -25360 / 2187, 64448 / 6561, -212 / 729),
|
|
242
|
+
(9017 / 3168, -355 / 33, 46732 / 5247, 49 / 176, -5103 / 18656),
|
|
243
|
+
(35 / 384, 0.0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84),
|
|
244
|
+
)
|
|
245
|
+
_DP_B5 = (35 / 384, 0.0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0.0)
|
|
246
|
+
_DP_B4 = (5179 / 57600, 0.0, 7571 / 16695, 393 / 640, -92097 / 339200, 187 / 2100, 1 / 40)
|
|
247
|
+
#: difference b5 - b4 — multiplied by h*k gives the local error estimate (order 4)
|
|
248
|
+
_DP_E = tuple(b5 - b4 for b5, b4 in zip(_DP_B5, _DP_B4))
|
|
249
|
+
|
|
250
|
+
#: DOPRI5 DENSE OUTPUT coefficients (continuous 4th-order extension, the same
|
|
251
|
+
#: as scipy RK45): for θ = (t - t_n)/h in [0,1] the interpolant is
|
|
252
|
+
#: y(t_n + θh) = y_n + h · Σ_i k_i · Σ_j P[i,j] θ^(j+1). At the nodes θ=0 -> y_n,
|
|
253
|
+
#: θ=1 -> y_{n+1} (Σ_j P[i,j] = b5_i), so it stitches the samples smoothly.
|
|
254
|
+
_DP_P = np.array(
|
|
255
|
+
[
|
|
256
|
+
[1.0, -8048581381 / 2820520608, 8663915743 / 2820520608, -12715105075 / 11282082432],
|
|
257
|
+
[0.0, 0.0, 0.0, 0.0],
|
|
258
|
+
[0.0, 131558114200 / 32700410799, -68118460800 / 10900136933, 87487479700 / 32700410799],
|
|
259
|
+
[0.0, -1754552775 / 470086768, 14199869525 / 1410260304, -10690763975 / 1880347072],
|
|
260
|
+
[0.0, 127303824393 / 49829197408, -318862633887 / 49829197408, 701980252875 / 199316789632],
|
|
261
|
+
[0.0, -282668133 / 205662961, 2019193451 / 616988883, -1453857185 / 822651844],
|
|
262
|
+
[0.0, 40617522 / 29380423, -110615467 / 29380423, 69997945 / 29380423],
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _dp_step(
|
|
268
|
+
sample: "Callable[[np.ndarray], np.ndarray | None]",
|
|
269
|
+
t: float,
|
|
270
|
+
y: np.ndarray,
|
|
271
|
+
h: float,
|
|
272
|
+
k1: np.ndarray,
|
|
273
|
+
) -> "tuple[np.ndarray, np.ndarray, list[np.ndarray]] | None":
|
|
274
|
+
"""One DOPRI5 step; return ``(y5, err, ks)`` or ``None`` (outside the domain).
|
|
275
|
+
|
|
276
|
+
``k1`` (= ``f(t, y)``) is passed in from outside — thanks to FSAL the next
|
|
277
|
+
step reuses the last stage of the previous one instead of recomputing it.
|
|
278
|
+
``y5`` is the 5th-order state, ``err`` = ``h·Σ(b5-b4)·k`` — the error
|
|
279
|
+
estimate; ``ks`` (7 stages) come back because they are the basis of the
|
|
280
|
+
dense output.
|
|
281
|
+
"""
|
|
282
|
+
ks: list[np.ndarray] = [k1]
|
|
283
|
+
for ci, ai in zip(_DP_C, _DP_A): # stages 2..7
|
|
284
|
+
yi = y + h * sum(ai[j] * ks[j] for j in range(len(ai)))
|
|
285
|
+
ki = sample(np.concatenate(([t + ci * h], yi)))
|
|
286
|
+
if ki is None:
|
|
287
|
+
return None
|
|
288
|
+
ks.append(ki)
|
|
289
|
+
y5 = y + h * sum(_DP_B5[i] * ks[i] for i in range(7))
|
|
290
|
+
err = h * sum(_DP_E[i] * ks[i] for i in range(7))
|
|
291
|
+
return y5, cast(np.ndarray, err), ks
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _dopri45(
|
|
295
|
+
sample: "Callable[[np.ndarray], np.ndarray | None]",
|
|
296
|
+
t0: float,
|
|
297
|
+
t1: float,
|
|
298
|
+
y: np.ndarray,
|
|
299
|
+
rtol: float,
|
|
300
|
+
atol: float,
|
|
301
|
+
max_steps: int,
|
|
302
|
+
what: str,
|
|
303
|
+
segments: "list | None" = None,
|
|
304
|
+
stop: "Callable | None" = None,
|
|
305
|
+
) -> tuple[list[float], list[list[float]]]:
|
|
306
|
+
"""Variable-step integration with the DOPRI5 pair over a COMPILED right-hand side.
|
|
307
|
+
|
|
308
|
+
A step is accepted when the normalized error norm (weight ``atol + rtol·|y|``)
|
|
309
|
+
does not exceed 1; then it is enlarged, and on rejection it is reduced and the
|
|
310
|
+
stage is repeated. The first stage of the next step reuses the last one of the
|
|
311
|
+
previous step (FSAL) — one fewer right-hand-side evaluation per step. Near a
|
|
312
|
+
singularity the step shrinks until below the ``hmin`` limit -> ``PycodemathError``
|
|
313
|
+
(integration REFUSES to jump, it does not return garbage). Returns ``(ts, rows)``
|
|
314
|
+
at the chosen nodes, including ``t0`` and ``t1``. A DECREASING interval
|
|
315
|
+
(``t1 < t0``) integrates BACKWARD — step ``h < 0``, decreasing nodes (module 13).
|
|
316
|
+
|
|
317
|
+
When ``segments`` (a list) is given — on each accepted step it appends a tuple
|
|
318
|
+
``(t_old, h, y_old, ks)`` for reconstructing the dense output (interpolation).
|
|
319
|
+
|
|
320
|
+
When ``stop`` is given — after each ACCEPTED step it calls
|
|
321
|
+
``stop(seg, t_new)``; a result ``(t_stop, y_stop)`` STOPS integration:
|
|
322
|
+
the last node is replaced by the stop point (a terminal event
|
|
323
|
+
— nodes end at ``t_stop``, not at ``t1``), and ``None`` continues.
|
|
324
|
+
"""
|
|
325
|
+
hmin = 1e-12 * max(1.0, abs(t0), abs(t1))
|
|
326
|
+
sgn = 1.0 if t1 > t0 else -1.0 # integration direction (backward: h < 0)
|
|
327
|
+
h = (t1 - t0) / 100.0 # modest starting step (carries the sign) — the controller spins it up
|
|
328
|
+
t = t0
|
|
329
|
+
ts = [t0]
|
|
330
|
+
rows = [[float(v) for v in y]]
|
|
331
|
+
steps = 0
|
|
332
|
+
k1 = sample(np.concatenate(([t], y))) # first stage (FSAL reuses the next ones)
|
|
333
|
+
if k1 is None:
|
|
334
|
+
raise PycodemathError(
|
|
335
|
+
f"{what}: cannot sample the right-hand side at the start point "
|
|
336
|
+
f"t={t:g} (outside the domain or complex result)"
|
|
337
|
+
)
|
|
338
|
+
while (t1 - t) * sgn > 0:
|
|
339
|
+
if steps >= max_steps:
|
|
340
|
+
raise PycodemathError(
|
|
341
|
+
f"{what}: exceeded {max_steps} steps at t={t:g} — "
|
|
342
|
+
f"the solution is probably singular (narrow the interval)"
|
|
343
|
+
)
|
|
344
|
+
last = (t + h - t1) * sgn >= 0
|
|
345
|
+
if last:
|
|
346
|
+
h = t1 - t # the last step lands exactly on t1
|
|
347
|
+
stepped = _dp_step(sample, t, y, h, k1)
|
|
348
|
+
if stepped is None: # right-hand side outside the domain — try shorter
|
|
349
|
+
h *= 0.5 # k1 still valid: t, y unchanged (sign preserved)
|
|
350
|
+
if abs(h) < hmin:
|
|
351
|
+
raise PycodemathError(
|
|
352
|
+
f"{what}: cannot sample the right-hand side at t={t:g} "
|
|
353
|
+
f"(singularity, complex result, or outside the domain)"
|
|
354
|
+
)
|
|
355
|
+
continue
|
|
356
|
+
y5, err, ks = stepped
|
|
357
|
+
sc = atol + rtol * np.maximum(np.abs(y), np.abs(y5))
|
|
358
|
+
e = float(np.sqrt(np.mean((err / sc) ** 2))) # RMS of the normalized error
|
|
359
|
+
if e <= 1.0: # --- step accepted ---
|
|
360
|
+
seg = (t, h, y, ks) # before the update: t_old, y_old
|
|
361
|
+
if segments is not None:
|
|
362
|
+
segments.append(seg)
|
|
363
|
+
# explicit t1 on the last step: t + (t1 - t) need not, in IEEE, hit
|
|
364
|
+
# t1 exactly, and a remainder of order ulp would make a duplicated mini-step
|
|
365
|
+
t = t1 if last else t + h
|
|
366
|
+
y = y5
|
|
367
|
+
k1 = ks[6] # FSAL: ks[6] = f(t+h, y5) = k1 of the next step
|
|
368
|
+
ts.append(t)
|
|
369
|
+
rows.append([float(v) for v in y])
|
|
370
|
+
steps += 1
|
|
371
|
+
if stop is not None:
|
|
372
|
+
hit = stop(seg, t)
|
|
373
|
+
if hit is not None: # terminal event inside the step
|
|
374
|
+
t_stop, y_stop = hit
|
|
375
|
+
ts[-1] = float(t_stop) # nodes end AT the event
|
|
376
|
+
rows[-1] = [float(v) for v in y_stop]
|
|
377
|
+
break
|
|
378
|
+
# safety factor 0.9, exponent 1/5 (order-4 estimate); growth <= 5x
|
|
379
|
+
grow = 5.0 if e == 0.0 else 0.9 * e ** -0.2
|
|
380
|
+
h *= min(5.0, grow)
|
|
381
|
+
else: # --- step rejected: reduce and repeat ---
|
|
382
|
+
h *= max(0.2, 0.9 * e ** -0.2) # k1 unchanged; positive multiplier — sign preserved
|
|
383
|
+
if abs(h) < hmin:
|
|
384
|
+
raise PycodemathError(
|
|
385
|
+
f"{what}: step shrank below the limit at t={t:g} — "
|
|
386
|
+
f"singularity (integration refuses to jump over it)"
|
|
387
|
+
)
|
|
388
|
+
return ts, rows
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def solve_ode_adaptive(
|
|
392
|
+
rhs: Expr,
|
|
393
|
+
var: str,
|
|
394
|
+
y0: float,
|
|
395
|
+
t_span: "Sequence[float]",
|
|
396
|
+
rtol: float = 1e-6,
|
|
397
|
+
atol: float = 1e-9,
|
|
398
|
+
max_steps: int = 100_000,
|
|
399
|
+
func: str = "y",
|
|
400
|
+
) -> tuple[list[float], list[float]]:
|
|
401
|
+
"""Initial value problem ``y'(var) = f(var, y)`` by the Dormand-Prince 5(4) method.
|
|
402
|
+
|
|
403
|
+
A variant of ``solve_ode_num`` with a VARIABLE step: the RK 5(4) pair
|
|
404
|
+
estimates the local error at each step and chooses its length so as to
|
|
405
|
+
stay within the tolerance ``atol + rtol·|y|``. On a smooth problem it
|
|
406
|
+
reaches the requested accuracy with FEWER steps than fixed-step RK4;
|
|
407
|
+
near a singularity the step densifies, and when it drops below the limit —
|
|
408
|
+
``PycodemathError`` (instead of a silent jump across a pole).
|
|
409
|
+
|
|
410
|
+
``rhs`` depends at most on ``var`` and ``func``. Returns samples
|
|
411
|
+
``(ts, ys)`` at adaptively chosen nodes, including ``t0`` and ``t1``
|
|
412
|
+
(the number of nodes depends on the course of the solution).
|
|
413
|
+
"""
|
|
414
|
+
r = Expr(rhs)
|
|
415
|
+
_check_vars([r], [var, func], "solve_ode_adaptive") # duplicates catch func == var
|
|
416
|
+
t0, t1 = _span(t_span, "solve_ode_adaptive")
|
|
417
|
+
rtol, atol = _tols(rtol, atol, "solve_ode_adaptive")
|
|
418
|
+
sample = _vector_fn([r], [var, func]) # compiled once — before the loop
|
|
419
|
+
ts, rows = _dopri45(
|
|
420
|
+
sample, t0, t1, np.array([float(y0)], dtype=float),
|
|
421
|
+
rtol, atol, int(max_steps), "solve_ode_adaptive",
|
|
422
|
+
)
|
|
423
|
+
return ts, [row[0] for row in rows]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _seg_eval(seg: tuple, tq: float) -> np.ndarray:
|
|
427
|
+
"""Read the state ``y(tq)`` from the interpolant of ONE step ``(t_old, h, y_old, ks)``.
|
|
428
|
+
|
|
429
|
+
The 4th-order continuous extension of DOPRI5: for ``θ = (tq - t_old)/h``
|
|
430
|
+
``y = y_old + h · Σ_i k_i · (P[i] · [θ, θ², θ³, θ⁴])``. At ``θ=0`` it returns
|
|
431
|
+
``y_old``, at ``θ=1`` exactly ``y_new`` (Σ_j P[i,j] = b5_i) — the shared
|
|
432
|
+
core of dense output and terminal event localization.
|
|
433
|
+
"""
|
|
434
|
+
t_old, h, y_old, ks = seg
|
|
435
|
+
theta = (tq - t_old) / h
|
|
436
|
+
powers = np.array([theta, theta**2, theta**3, theta**4])
|
|
437
|
+
acc = sum(ks[i] * float(_DP_P[i] @ powers) for i in range(7))
|
|
438
|
+
return y_old + h * acc
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class DenseSolution:
|
|
442
|
+
"""DOPRI5 dense output — callable ``sol(t)`` reading ``y`` at EVERY ``t``.
|
|
443
|
+
|
|
444
|
+
``solve_ode_adaptive`` gives ``y`` only at the nodes; here each accepted
|
|
445
|
+
step carries its own 4th-order interpolant (the continuous extension of
|
|
446
|
+
DOPRI5 on the stages ``k``), so the solution is read at any point of the
|
|
447
|
+
interval without re-integrating. The sample nodes remain available as
|
|
448
|
+
``.ts``/``.ys``.
|
|
449
|
+
|
|
450
|
+
``sol(t)`` accepts a number (returns ``float``) or a sequence/array
|
|
451
|
+
(returns ``np.ndarray``); ``t`` outside ``[t0, t1]`` -> ``PycodemathError``.
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
def __init__(self, ts: list[float], rows: list[list[float]], segments: list):
|
|
455
|
+
self.ts = ts
|
|
456
|
+
self.ys: list = [row[0] for row in rows] # scalar y at nodes (system subclasses: state lists)
|
|
457
|
+
self.t0 = ts[0]
|
|
458
|
+
self.t1 = ts[-1]
|
|
459
|
+
self._segments = segments # (t_old, h, y_old, ks) per step
|
|
460
|
+
# integration direction: for a BACKWARD run (t1 < t0) the nodes decrease;
|
|
461
|
+
# multiplying by the sign brings the step t_old values to increasing order,
|
|
462
|
+
# so searchsorted works in both directions
|
|
463
|
+
self._sgn = 1.0 if self.t1 >= self.t0 else -1.0
|
|
464
|
+
self._starts = self._sgn * np.array([seg[0] for seg in segments])
|
|
465
|
+
|
|
466
|
+
def _eval_vec(self, tq: float) -> np.ndarray:
|
|
467
|
+
"""Read the FULL state vector at ``tq`` (shared core of scalar and system)."""
|
|
468
|
+
lo, hi = sorted((self.t0, self.t1))
|
|
469
|
+
# boundary tolerance: allow a small float overshoot at the ends
|
|
470
|
+
eps = 1e-9 * max(1.0, abs(lo), abs(hi))
|
|
471
|
+
if tq < lo - eps or tq > hi + eps:
|
|
472
|
+
raise PycodemathError(
|
|
473
|
+
f"DenseSolution: t={tq:g} outside the interval "
|
|
474
|
+
f"[{self.t0:g}, {self.t1:g}]"
|
|
475
|
+
)
|
|
476
|
+
# step containing tq: the last t_old <= tq ALONG the integration direction
|
|
477
|
+
idx = int(np.searchsorted(self._starts, self._sgn * tq, side="right") - 1)
|
|
478
|
+
idx = min(max(idx, 0), len(self._segments) - 1)
|
|
479
|
+
return _seg_eval(self._segments[idx], tq)
|
|
480
|
+
|
|
481
|
+
def _eval_one(self, tq: float) -> float:
|
|
482
|
+
return float(self._eval_vec(tq)[0])
|
|
483
|
+
|
|
484
|
+
def __call__(self, t):
|
|
485
|
+
"""Read ``y(t)`` — a scalar for a number, ``np.ndarray`` for a sequence."""
|
|
486
|
+
if np.ndim(t) == 0:
|
|
487
|
+
return self._eval_one(float(t))
|
|
488
|
+
return np.array([self._eval_one(float(v)) for v in t])
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def solve_ode_dense(
|
|
492
|
+
rhs: Expr,
|
|
493
|
+
var: str,
|
|
494
|
+
y0: float,
|
|
495
|
+
t_span: "Sequence[float]",
|
|
496
|
+
rtol: float = 1e-6,
|
|
497
|
+
atol: float = 1e-9,
|
|
498
|
+
max_steps: int = 100_000,
|
|
499
|
+
func: str = "y",
|
|
500
|
+
) -> DenseSolution:
|
|
501
|
+
"""Like ``solve_ode_adaptive``, but returns DENSE OUTPUT ``DenseSolution``.
|
|
502
|
+
|
|
503
|
+
The engine and step control are identical (the DOPRI5 pair, tolerance
|
|
504
|
+
``atol + rtol·|y|``) — the nodes ``.ts``/``.ys`` are the same as in
|
|
505
|
+
``solve_ode_adaptive``. The difference: the object is CALLABLE and reads
|
|
506
|
+
``y(t)`` at any point of ``[t0, t1]`` via a 4th-order interpolant (not only
|
|
507
|
+
at nodes). Singularity / bad input -> ``PycodemathError`` as above.
|
|
508
|
+
"""
|
|
509
|
+
r = Expr(rhs)
|
|
510
|
+
_check_vars([r], [var, func], "solve_ode_dense") # duplicates catch func == var
|
|
511
|
+
t0, t1 = _span(t_span, "solve_ode_dense")
|
|
512
|
+
rtol, atol = _tols(rtol, atol, "solve_ode_dense")
|
|
513
|
+
sample = _vector_fn([r], [var, func]) # compiled once — before the loop
|
|
514
|
+
segments: list = []
|
|
515
|
+
ts, rows = _dopri45(
|
|
516
|
+
sample, t0, t1, np.array([float(y0)], dtype=float),
|
|
517
|
+
rtol, atol, int(max_steps), "solve_ode_dense", segments,
|
|
518
|
+
)
|
|
519
|
+
return DenseSolution(ts, rows, segments)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# --- event detection (module 9) ------------------------------------------
|
|
523
|
+
def _bisect_event(
|
|
524
|
+
G: "Callable[[float], float]",
|
|
525
|
+
a: float,
|
|
526
|
+
ga: float,
|
|
527
|
+
b: float,
|
|
528
|
+
gb: float,
|
|
529
|
+
xtol: float = 1e-13,
|
|
530
|
+
max_iter: int = 100,
|
|
531
|
+
) -> float:
|
|
532
|
+
"""Locate a zero of ``G`` in ``[a, b]`` (known sign-changing ``ga``, ``gb``).
|
|
533
|
+
|
|
534
|
+
Bisection on the SCALAR function ``G(t) = g(t, sol(t))`` — sol(t) is a ready
|
|
535
|
+
interpolant, so localization does NOT require re-integrating. Returns the
|
|
536
|
+
``t`` with the zero to tolerance ``xtol`` (relative on the scale of ``|t|``).
|
|
537
|
+
The order of the ends is arbitrary (``a > b`` for backward integration) —
|
|
538
|
+
the width is computed via ``abs``.
|
|
539
|
+
"""
|
|
540
|
+
for _ in range(max_iter):
|
|
541
|
+
m = 0.5 * (a + b)
|
|
542
|
+
gm = G(m)
|
|
543
|
+
if gm == 0.0 or 0.5 * abs(b - a) <= xtol * max(1.0, abs(m)):
|
|
544
|
+
return m
|
|
545
|
+
if (ga < 0.0) != (gm < 0.0): # sign change in [a, m]
|
|
546
|
+
b, gb = m, gm
|
|
547
|
+
else:
|
|
548
|
+
a, ga = m, gm
|
|
549
|
+
return 0.5 * (a + b)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _find_events(
|
|
553
|
+
sol: "DenseSolution",
|
|
554
|
+
g_fn: "Callable[[np.ndarray], np.ndarray | None]",
|
|
555
|
+
direction: int,
|
|
556
|
+
what: str,
|
|
557
|
+
) -> list[tuple[float, np.ndarray]]:
|
|
558
|
+
"""Scan the nodes of ``sol`` and find zeros of ``g(t, y)`` (sign changes).
|
|
559
|
+
|
|
560
|
+
On each segment [t_k, t_{k+1}] we check the sign of ``G`` at the nodes; at a
|
|
561
|
+
strict sign change we locate the zero by bisection on the interpolant.
|
|
562
|
+
``direction``: +1 only rising (−→+), −1 only falling (+→−),
|
|
563
|
+
0 any — the transition direction is measured ALONG the integration run
|
|
564
|
+
(for backward integration the nodes go down the t axis). Returns a list of
|
|
565
|
+
``(t_event, state_vector)`` in time order — the caller flattens the state to
|
|
566
|
+
a scalar or a list (system).
|
|
567
|
+
"""
|
|
568
|
+
def G(t: float) -> float:
|
|
569
|
+
vec = sol._eval_vec(t)
|
|
570
|
+
val = g_fn(np.concatenate(([float(t)], vec)))
|
|
571
|
+
if val is None:
|
|
572
|
+
raise PycodemathError(
|
|
573
|
+
f"{what}: event function outside the domain at t={t:g} "
|
|
574
|
+
f"(complex result or NaN/inf)"
|
|
575
|
+
)
|
|
576
|
+
return float(val[0])
|
|
577
|
+
|
|
578
|
+
events: list[tuple[float, np.ndarray]] = []
|
|
579
|
+
ts = sol.ts
|
|
580
|
+
g_prev = G(ts[0])
|
|
581
|
+
for k in range(1, len(ts)):
|
|
582
|
+
t_a, t_b = ts[k - 1], ts[k]
|
|
583
|
+
g_b = G(t_b)
|
|
584
|
+
if g_prev < 0.0 < g_b or g_prev > 0.0 > g_b: # strict sign change
|
|
585
|
+
cross = 1 if g_b > g_prev else -1 # rising / falling
|
|
586
|
+
if direction == 0 or direction == cross:
|
|
587
|
+
t_ev = _bisect_event(G, t_a, g_prev, t_b, g_b)
|
|
588
|
+
events.append((t_ev, sol._eval_vec(t_ev)))
|
|
589
|
+
g_prev = g_b
|
|
590
|
+
return events
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class EventSolution(DenseSolution):
|
|
594
|
+
"""``DenseSolution`` enriched with detected events ``g(t, y) = 0``.
|
|
595
|
+
|
|
596
|
+
Inherits the full dense-output interface (still CALLABLE ``sol(t)``,
|
|
597
|
+
``.ts``/``.ys``) and adds a list ``.events`` = ``[(t, y), …]`` at the
|
|
598
|
+
moments the event function crosses zero, plus separated ``.event_times``
|
|
599
|
+
and ``.event_values`` for convenient use in NumPy.
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
def __init__(
|
|
603
|
+
self,
|
|
604
|
+
ts: list[float],
|
|
605
|
+
rows: list[list[float]],
|
|
606
|
+
segments: list,
|
|
607
|
+
events: list[tuple[float, float]],
|
|
608
|
+
):
|
|
609
|
+
super().__init__(ts, rows, segments)
|
|
610
|
+
self.events = events
|
|
611
|
+
self.event_times = [t for t, _ in events]
|
|
612
|
+
self.event_values = [y for _, y in events]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _terminal_stop(
|
|
616
|
+
g_fn: "Callable[[np.ndarray], np.ndarray | None]",
|
|
617
|
+
direction: int,
|
|
618
|
+
what: str,
|
|
619
|
+
) -> Callable:
|
|
620
|
+
"""Build the ``stop`` callback for ``_dopri45`` — a TERMINAL event.
|
|
621
|
+
|
|
622
|
+
After each accepted step it computes ``G`` at its ends (``G(t) =
|
|
623
|
+
g(t, y(t))`` on the interpolant of THIS step); at a sign change consistent
|
|
624
|
+
with ``direction`` it locates the zero by bisection and returns ``(t_ev, y_ev)`` —
|
|
625
|
+
the stop signal. A hit is stored in ``stop.hit``. The sign from the previous
|
|
626
|
+
step is carried by the closure — G computed ONCE per node, as in ``_find_events``.
|
|
627
|
+
"""
|
|
628
|
+
state: dict = {"g_prev": None}
|
|
629
|
+
|
|
630
|
+
def stop(seg: tuple, t_new: float) -> "tuple[float, np.ndarray] | None":
|
|
631
|
+
def G(tq: float) -> float:
|
|
632
|
+
val = g_fn(np.concatenate(([tq], _seg_eval(seg, tq))))
|
|
633
|
+
if val is None:
|
|
634
|
+
raise PycodemathError(
|
|
635
|
+
f"{what}: event function outside the domain at t={tq:g} "
|
|
636
|
+
f"(complex result or NaN/inf)"
|
|
637
|
+
)
|
|
638
|
+
return float(val[0])
|
|
639
|
+
|
|
640
|
+
t_old = seg[0]
|
|
641
|
+
if state["g_prev"] is None: # first step: sign at the start point
|
|
642
|
+
state["g_prev"] = G(t_old)
|
|
643
|
+
g_a = state["g_prev"]
|
|
644
|
+
g_b = G(t_new)
|
|
645
|
+
state["g_prev"] = g_b
|
|
646
|
+
if g_a < 0.0 < g_b or g_a > 0.0 > g_b: # strict sign change
|
|
647
|
+
cross = 1 if g_b > g_a else -1
|
|
648
|
+
if direction == 0 or direction == cross:
|
|
649
|
+
t_ev = _bisect_event(G, t_old, g_a, t_new, g_b)
|
|
650
|
+
y_ev = _seg_eval(seg, t_ev)
|
|
651
|
+
stop.hit = (t_ev, y_ev) # type: ignore[attr-defined] # result channel: attribute on the closure
|
|
652
|
+
return t_ev, y_ev
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
stop.hit = None # type: ignore[attr-defined] # result channel: attribute on the closure
|
|
656
|
+
return stop
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def solve_ode_events(
|
|
660
|
+
rhs: Expr,
|
|
661
|
+
var: str,
|
|
662
|
+
y0: float,
|
|
663
|
+
t_span: "Sequence[float]",
|
|
664
|
+
event: Expr,
|
|
665
|
+
direction: int = 0,
|
|
666
|
+
terminal: bool = False,
|
|
667
|
+
rtol: float = 1e-6,
|
|
668
|
+
atol: float = 1e-9,
|
|
669
|
+
max_steps: int = 100_000,
|
|
670
|
+
func: str = "y",
|
|
671
|
+
) -> EventSolution:
|
|
672
|
+
"""Integrate ``y' = f(var, y)`` and detect zeros of the event function ``g(var, y)``.
|
|
673
|
+
|
|
674
|
+
The engine and step control are as in ``solve_ode_dense`` (the DOPRI5 pair,
|
|
675
|
+
tolerance ``atol + rtol·|y|``); after integration we scan the nodes and where
|
|
676
|
+
``g`` changes sign we locate the zero by BISECTION on the interpolant —
|
|
677
|
+
without re-integrating. ``event`` is an IR ``Expr`` depending at most on
|
|
678
|
+
``var`` and ``func``. ``direction``: +1 only rising transitions,
|
|
679
|
+
−1 only falling, 0 (default) any.
|
|
680
|
+
|
|
681
|
+
``terminal=True`` STOPS integration at the FIRST event (consistent
|
|
682
|
+
with ``direction``): events are checked ON THE FLY after each step,
|
|
683
|
+
the nodes and ``sol(t)`` end exactly at ``t_event`` (~rtol), not
|
|
684
|
+
at ``t1`` — so a singularity lying BEYOND the event does not interrupt integration.
|
|
685
|
+
``.events`` then has at most one entry; no transition -> the full
|
|
686
|
+
interval and an empty list (as before).
|
|
687
|
+
|
|
688
|
+
Returns ``EventSolution`` — still callable ``sol(t)`` (dense output),
|
|
689
|
+
with a list ``.events`` = ``[(t, y), …]``. Singularity / bad input ->
|
|
690
|
+
``PycodemathError``.
|
|
691
|
+
"""
|
|
692
|
+
r = Expr(rhs)
|
|
693
|
+
g = Expr(event)
|
|
694
|
+
_check_vars([r], [var, func], "solve_ode_events") # duplicates catch func == var
|
|
695
|
+
_check_vars([g], [var, func], "solve_ode_events (event function)")
|
|
696
|
+
if direction not in (-1, 0, 1):
|
|
697
|
+
raise PycodemathError("solve_ode_events: direction must be -1, 0, or +1")
|
|
698
|
+
t0, t1 = _span(t_span, "solve_ode_events")
|
|
699
|
+
rtol, atol = _tols(rtol, atol, "solve_ode_events")
|
|
700
|
+
sample = _vector_fn([r], [var, func]) # compiled once — before the loop
|
|
701
|
+
g_fn = _vector_fn([g], [var, func]) # event function — compiled once
|
|
702
|
+
segments: list = []
|
|
703
|
+
|
|
704
|
+
if terminal: # event checked DURING integration (stop on hit)
|
|
705
|
+
stop = _terminal_stop(g_fn, int(direction), "solve_ode_events")
|
|
706
|
+
ts, rows = _dopri45(
|
|
707
|
+
sample, t0, t1, np.array([float(y0)], dtype=float),
|
|
708
|
+
rtol, atol, int(max_steps), "solve_ode_events",
|
|
709
|
+
segments, stop,
|
|
710
|
+
)
|
|
711
|
+
events = []
|
|
712
|
+
if stop.hit is not None: # type: ignore[attr-defined]
|
|
713
|
+
t_ev, y_ev = stop.hit # type: ignore[attr-defined]
|
|
714
|
+
events = [(float(t_ev), float(y_ev[0]))]
|
|
715
|
+
return EventSolution(ts, rows, segments, events)
|
|
716
|
+
|
|
717
|
+
ts, rows = _dopri45(
|
|
718
|
+
sample, t0, t1, np.array([float(y0)], dtype=float),
|
|
719
|
+
rtol, atol, int(max_steps), "solve_ode_events", segments,
|
|
720
|
+
)
|
|
721
|
+
sol = DenseSolution(ts, rows, segments) # interpolant for locating zeros
|
|
722
|
+
raw = _find_events(sol, g_fn, int(direction), "solve_ode_events")
|
|
723
|
+
events = [(t, float(vec[0])) for t, vec in raw] # scalar y of the event
|
|
724
|
+
return EventSolution(ts, rows, segments, events)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def solve_ode_system_num(
|
|
728
|
+
rhs: "Sequence[Expr]",
|
|
729
|
+
var: str,
|
|
730
|
+
funcs: "Sequence[str]",
|
|
731
|
+
y0: "Sequence[float]",
|
|
732
|
+
t_span: "Sequence[float]",
|
|
733
|
+
n: int = 100,
|
|
734
|
+
) -> tuple[list[float], list[list[float]]]:
|
|
735
|
+
"""System ``y' = F(var, y)`` vectorized — fixed-step RK4.
|
|
736
|
+
|
|
737
|
+
``rhs[i]`` is the right-hand side of the equation for ``funcs[i]``; each
|
|
738
|
+
expression depends at most on ``var`` and names from ``funcs``. Returns
|
|
739
|
+
``(ts, rows)``, where ``rows[k][i]`` = value of ``funcs[i]`` at time
|
|
740
|
+
``ts[k]`` (state-row per sample — a natural ``zip(ts, rows)``).
|
|
741
|
+
"""
|
|
742
|
+
fs, funcs, y = _system_inputs(rhs, var, funcs, y0, "solve_ode_system_num")
|
|
743
|
+
t0, t1, _ = _grid(t_span, n, "solve_ode_system_num")
|
|
744
|
+
sample = _vector_fn(fs, [var, *funcs])
|
|
745
|
+
return _rk4(sample, t0, t1, int(n), y, "solve_ode_system_num")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# --- stiff methods (module 12) --------------------------------------------
|
|
749
|
+
def solve_ode_stiff(
|
|
750
|
+
rhs: Expr,
|
|
751
|
+
var: str,
|
|
752
|
+
y0: float,
|
|
753
|
+
t_span: "Sequence[float]",
|
|
754
|
+
n: int = 100,
|
|
755
|
+
func: str = "y",
|
|
756
|
+
) -> tuple[list[float], list[float]]:
|
|
757
|
+
"""Initial value problem ``y' = f(var, y)`` by an IMPLICIT method (BDF2).
|
|
758
|
+
|
|
759
|
+
For STIFF equations — where the damping time scale is orders of magnitude
|
|
760
|
+
shorter than the solution scale (e.g. ``y' = -1000·(y - cos(t))``) — explicit
|
|
761
|
+
methods (RK4, DOPRI5) are limited by STABILITY, not accuracy: the step must
|
|
762
|
+
be tiny, because a larger one explodes. Implicit methods are A-stable:
|
|
763
|
+
the step is matched to the solution, not to the stiffness.
|
|
764
|
+
|
|
765
|
+
BDF2 (multistep, order 2): ``y_{k+1} = (4·y_k - y_{k-1})/3
|
|
766
|
+
+ (2h/3)·f(t_{k+1}, y_{k+1})``; the implicit Euler starts the first step.
|
|
767
|
+
The step equation is solved by NEWTON with the derivative ``∂f/∂y`` computed
|
|
768
|
+
SYMBOLICALLY (``Expr.diff``, the ``root_find_nd`` pattern) and compiled
|
|
769
|
+
ONCE before the loop. Fixed step ``h = (t1-t0)/n``; returns ``(ts, ys)``
|
|
770
|
+
like ``solve_ode_num``. Divergence / Newton non-convergence ->
|
|
771
|
+
``PycodemathError``.
|
|
772
|
+
"""
|
|
773
|
+
r = Expr(rhs)
|
|
774
|
+
_check_vars([r], [var, func], "solve_ode_stiff") # duplicates catch func == var
|
|
775
|
+
t0, t1, h = _grid(t_span, n, "solve_ode_stiff")
|
|
776
|
+
n = int(n)
|
|
777
|
+
f_fn = _vector_fn([r], [var, func]) # compiled once — before the loop
|
|
778
|
+
df_fn = _vector_fn([r.diff(func)], [var, func]) # ∂f/∂y symbolically
|
|
779
|
+
|
|
780
|
+
def _sample1(fn, t: float, u: float) -> "float | None":
|
|
781
|
+
val = fn(np.array([t, u]))
|
|
782
|
+
return None if val is None else float(val[0])
|
|
783
|
+
|
|
784
|
+
def _implicit_solve(t_new: float, const: float, coef: float, u0: float) -> float:
|
|
785
|
+
"""Solve ``u = const + coef·f(t_new, u)`` by Newton (start ``u0``)."""
|
|
786
|
+
u = u0
|
|
787
|
+
for _ in range(50):
|
|
788
|
+
fu = _sample1(f_fn, t_new, u)
|
|
789
|
+
if fu is None:
|
|
790
|
+
raise PycodemathError(
|
|
791
|
+
f"solve_ode_stiff: cannot sample the right-hand side at "
|
|
792
|
+
f"t={t_new:g} (outside the domain or complex result)"
|
|
793
|
+
)
|
|
794
|
+
res = u - coef * fu - const
|
|
795
|
+
if abs(res) <= 1e-12 * max(1.0, abs(u)):
|
|
796
|
+
return u
|
|
797
|
+
dfu = _sample1(df_fn, t_new, u)
|
|
798
|
+
slope = None if dfu is None else 1.0 - coef * dfu
|
|
799
|
+
if slope is None or slope == 0.0:
|
|
800
|
+
raise PycodemathError(
|
|
801
|
+
f"solve_ode_stiff: Newton on the implicit step cannot proceed at "
|
|
802
|
+
f"t={t_new:g} (derivative outside the domain or singular)"
|
|
803
|
+
)
|
|
804
|
+
u = u - res / slope
|
|
805
|
+
if not np.isfinite(u):
|
|
806
|
+
raise PycodemathError(
|
|
807
|
+
f"solve_ode_stiff: solution diverges at "
|
|
808
|
+
f"t={t_new:g} (NaN/inf) — narrow the interval or increase n"
|
|
809
|
+
)
|
|
810
|
+
raise PycodemathError(
|
|
811
|
+
f"solve_ode_stiff: Newton does not converge at t={t_new:g} — increase n"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
ts = [t0]
|
|
815
|
+
ys = [float(y0)]
|
|
816
|
+
# start-up: BDF2 needs two points — the first step is implicit
|
|
817
|
+
# Euler (u = y_k + h·f(t_new, u)); Newton starts from the previous value
|
|
818
|
+
y1 = _implicit_solve(t0 + h, ys[0], h, ys[0])
|
|
819
|
+
ts.append(t0 + h)
|
|
820
|
+
ys.append(y1)
|
|
821
|
+
for i in range(2, n + 1):
|
|
822
|
+
t_new = t0 + i * h
|
|
823
|
+
const = (4.0 * ys[-1] - ys[-2]) / 3.0
|
|
824
|
+
u = _implicit_solve(t_new, const, 2.0 * h / 3.0, ys[-1])
|
|
825
|
+
ts.append(t_new)
|
|
826
|
+
ys.append(u)
|
|
827
|
+
return ts, ys
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# --- stiff systems (module 16) ---------------------------------------------
|
|
831
|
+
def solve_ode_system_stiff(
|
|
832
|
+
rhs: "Sequence[Expr]",
|
|
833
|
+
var: str,
|
|
834
|
+
funcs: "Sequence[str]",
|
|
835
|
+
y0: "Sequence[float]",
|
|
836
|
+
t_span: "Sequence[float]",
|
|
837
|
+
n: int = 100,
|
|
838
|
+
) -> tuple[list[float], list[list[float]]]:
|
|
839
|
+
"""STIFF system ``y' = F(var, y)`` by an IMPLICIT method (BDF2) — vectorized.
|
|
840
|
+
|
|
841
|
+
The vector counterpart of ``solve_ode_stiff``: the same BDF2 with an
|
|
842
|
+
implicit Euler start-up, but the step equation ``u = const + coef·F(t_new, u)``
|
|
843
|
+
is solved by a MULTIDIMENSIONAL NEWTON (the ``root_find_nd`` pattern): residual
|
|
844
|
+
``R(u) = u − coef·F(t_new, u) − const``, step matrix ``I − coef·J``,
|
|
845
|
+
where ``J = ∂F/∂y`` computed SYMBOLICALLY (``Expr.diff``, flattened
|
|
846
|
+
row by row) and compiled ONCE before the loop; the linear system via
|
|
847
|
+
``np.linalg.solve``. Iteration start: the previous state ``y_k`` (as in the
|
|
848
|
+
scalar version — an explicit predictor for stiff problems flies off into space).
|
|
849
|
+
|
|
850
|
+
``rhs[i]`` is the right-hand side of the equation for ``funcs[i]``; the
|
|
851
|
+
expressions depend at most on ``var`` and ``funcs``. Fixed step ``h = (t1-t0)/n``;
|
|
852
|
+
a DECREASING interval (t1 < t0) integrates BACKWARD (module 13). Returns
|
|
853
|
+
``(ts, rows)`` like ``solve_ode_system_num``. Singular step matrix /
|
|
854
|
+
Newton non-convergence / divergence -> ``PycodemathError``.
|
|
855
|
+
"""
|
|
856
|
+
fs, funcs, y = _system_inputs(rhs, var, funcs, y0, "solve_ode_system_stiff")
|
|
857
|
+
t0, t1, h = _grid(t_span, n, "solve_ode_system_stiff")
|
|
858
|
+
n = int(n)
|
|
859
|
+
m = len(funcs)
|
|
860
|
+
F_fn = _vector_fn(fs, [var, *funcs]) # compiled once — before the loop
|
|
861
|
+
# Jacobian ∂F/∂y symbolically, flattened row by row (root_find_nd)
|
|
862
|
+
J_fn = _vector_fn([f.diff(g) for f in fs for g in funcs], [var, *funcs])
|
|
863
|
+
eye = np.eye(m)
|
|
864
|
+
|
|
865
|
+
def _implicit_solve_nd(
|
|
866
|
+
t_new: float, const: np.ndarray, coef: float, u0: np.ndarray
|
|
867
|
+
) -> np.ndarray:
|
|
868
|
+
"""Solve ``u = const + coef·F(t_new, u)`` by nd Newton (start ``u0``)."""
|
|
869
|
+
u = u0
|
|
870
|
+
for _ in range(50):
|
|
871
|
+
Fu = F_fn(np.concatenate(([t_new], u)))
|
|
872
|
+
if Fu is None:
|
|
873
|
+
raise PycodemathError(
|
|
874
|
+
f"solve_ode_system_stiff: cannot sample the right-hand side "
|
|
875
|
+
f"at t={t_new:g} (outside the domain or complex result)"
|
|
876
|
+
)
|
|
877
|
+
res = u - coef * Fu - const
|
|
878
|
+
if float(np.max(np.abs(res))) <= 1e-12 * max(
|
|
879
|
+
1.0, float(np.max(np.abs(u)))
|
|
880
|
+
):
|
|
881
|
+
return u
|
|
882
|
+
Ju = J_fn(np.concatenate(([t_new], u)))
|
|
883
|
+
if Ju is None:
|
|
884
|
+
raise PycodemathError(
|
|
885
|
+
f"solve_ode_system_stiff: cannot sample the Jacobian "
|
|
886
|
+
f"at t={t_new:g} (outside the domain or complex result)"
|
|
887
|
+
)
|
|
888
|
+
try:
|
|
889
|
+
delta = np.linalg.solve(eye - coef * Ju.reshape(m, m), -res)
|
|
890
|
+
except np.linalg.LinAlgError as exc:
|
|
891
|
+
raise PycodemathError(
|
|
892
|
+
f"solve_ode_system_stiff: implicit step matrix is singular "
|
|
893
|
+
f"at t={t_new:g} — Newton cannot proceed (increase n)"
|
|
894
|
+
) from exc
|
|
895
|
+
u = u + delta
|
|
896
|
+
if not np.all(np.isfinite(u)):
|
|
897
|
+
raise PycodemathError(
|
|
898
|
+
f"solve_ode_system_stiff: solution diverges at "
|
|
899
|
+
f"t={t_new:g} (NaN/inf) — narrow the interval or increase n"
|
|
900
|
+
)
|
|
901
|
+
raise PycodemathError(
|
|
902
|
+
f"solve_ode_system_stiff: Newton does not converge at t={t_new:g} — increase n"
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
ts = [t0]
|
|
906
|
+
rows = [[float(v) for v in y]]
|
|
907
|
+
# start-up: implicit Euler (u = y_0 + h·F(t_1, u)) — as in the scalar version
|
|
908
|
+
y1 = _implicit_solve_nd(t0 + h, y, h, y)
|
|
909
|
+
ts.append(t0 + h)
|
|
910
|
+
rows.append([float(v) for v in y1])
|
|
911
|
+
prev, curr = y, y1
|
|
912
|
+
for i in range(2, n + 1):
|
|
913
|
+
t_new = t0 + i * h
|
|
914
|
+
const = (4.0 * curr - prev) / 3.0
|
|
915
|
+
u = _implicit_solve_nd(t_new, const, 2.0 * h / 3.0, curr)
|
|
916
|
+
ts.append(t_new)
|
|
917
|
+
rows.append([float(v) for v in u])
|
|
918
|
+
prev, curr = curr, u
|
|
919
|
+
return ts, rows
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
# --- adaptive step for BDF (module 17) -------------------------------------
|
|
923
|
+
# Variable-step BDF2: the derivative of the quadratic interpolant through
|
|
924
|
+
# (t_{n-1}, y_{n-1}), (t_n, y_n), (t_{n+1}, u) set equal to f(t_{n+1}, u).
|
|
925
|
+
# For the step ratio ω = h_new/h_old the step equation has the form
|
|
926
|
+
# u = a1·y_n + a2·y_{n-1} + coef·f(t_{n+1}, u),
|
|
927
|
+
# a1 = (1+ω)²/(1+2ω), a2 = −ω²/(1+2ω), coef = h_new·(1+ω)/(1+2ω)
|
|
928
|
+
# (ω = 1 reproduces the fixed-step 4/3, −1/3, 2h/3 from module 12). The local
|
|
929
|
+
# error (LTE, derived from the interpolation remainder):
|
|
930
|
+
# LTE = (1+ω)²/(ω(1+2ω)) · h_new³ · y'''/6 + O(h⁴),
|
|
931
|
+
# and y''' is estimated by the THIRD DIVIDED DIFFERENCE (y''' ≈ 6·f[t_{n-2},…,t_{n+1}])
|
|
932
|
+
# from the three last nodes and the fresh u — the factor 6 cancels.
|
|
933
|
+
# Zero-stability of variable-step BDF2 requires ω < 1+√2 — step growth
|
|
934
|
+
# is limited to 2x per step.
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _dd3(ts4: "Sequence[float]", ys4: "Sequence[np.ndarray]") -> np.ndarray:
|
|
938
|
+
"""Third divided difference ``f[t0,t1,t2,t3]`` (vectorized per coordinate)."""
|
|
939
|
+
d1 = [(ys4[i + 1] - ys4[i]) / (ts4[i + 1] - ts4[i]) for i in range(3)]
|
|
940
|
+
d2 = [(d1[i + 1] - d1[i]) / (ts4[i + 2] - ts4[i]) for i in range(2)]
|
|
941
|
+
return (d2[1] - d2[0]) / (ts4[3] - ts4[0])
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _bdf_adaptive(
|
|
945
|
+
F_fn: "Callable[[np.ndarray], np.ndarray | None]",
|
|
946
|
+
J_fn: "Callable[[np.ndarray], np.ndarray | None]",
|
|
947
|
+
m: int,
|
|
948
|
+
t0: float,
|
|
949
|
+
t1: float,
|
|
950
|
+
y: np.ndarray,
|
|
951
|
+
rtol: float,
|
|
952
|
+
atol: float,
|
|
953
|
+
max_steps: int,
|
|
954
|
+
what: str,
|
|
955
|
+
) -> tuple[list[float], list[list[float]]]:
|
|
956
|
+
"""Core of the adaptive BDF over a COMPILED right-hand side (scalar and system).
|
|
957
|
+
|
|
958
|
+
Step controller as in ``_dopri45``: weight ``atol + rtol·|y|``, RMS of the
|
|
959
|
+
normalized error, safety factor 0.9, rejection and repeat when
|
|
960
|
+
``e > 1``; exponent matched to the estimate order (1/3 for order-3 LTE,
|
|
961
|
+
1/2 for order-2 start-up estimates). Start-up: the first step is BDF1
|
|
962
|
+
with HALVING (error from comparing the full step and two halves),
|
|
963
|
+
the second — variable-step BDF2 with an estimate from the BDF1/BDF2
|
|
964
|
+
corrector pair; from the third the full estimate from the divided
|
|
965
|
+
difference. Newton non-convergence / singular matrix / outside the domain
|
|
966
|
+
do NOT topple integration — the step is REJECTED and halved; only
|
|
967
|
+
``|h| < hmin`` (singularity) or ``max_steps`` gives ``PycodemathError``.
|
|
968
|
+
A decreasing interval integrates BACKWARD (h < 0, ω > 0 — module 13 for free).
|
|
969
|
+
"""
|
|
970
|
+
hmin = 1e-12 * max(1.0, abs(t0), abs(t1))
|
|
971
|
+
sgn = 1.0 if t1 > t0 else -1.0
|
|
972
|
+
h = (t1 - t0) / 100.0 # modest starting step — the controller matches it
|
|
973
|
+
eye = np.eye(m)
|
|
974
|
+
|
|
975
|
+
def newton(t_new: float, const: np.ndarray, coef: float, u0: np.ndarray):
|
|
976
|
+
"""Solve ``u = const + coef·F(t_new, u)``; ``None`` = step to reject."""
|
|
977
|
+
u = u0
|
|
978
|
+
for _ in range(50):
|
|
979
|
+
Fu = F_fn(np.concatenate(([t_new], u)))
|
|
980
|
+
if Fu is None:
|
|
981
|
+
return None
|
|
982
|
+
res = u - coef * Fu - const
|
|
983
|
+
if float(np.max(np.abs(res))) <= 1e-12 * max(
|
|
984
|
+
1.0, float(np.max(np.abs(u)))
|
|
985
|
+
):
|
|
986
|
+
return u
|
|
987
|
+
Ju = J_fn(np.concatenate(([t_new], u)))
|
|
988
|
+
if Ju is None:
|
|
989
|
+
return None
|
|
990
|
+
try:
|
|
991
|
+
delta = np.linalg.solve(eye - coef * Ju.reshape(m, m), -res)
|
|
992
|
+
except np.linalg.LinAlgError:
|
|
993
|
+
return None # a smaller step brings the matrix closer to I
|
|
994
|
+
u = u + delta
|
|
995
|
+
if not np.all(np.isfinite(u)):
|
|
996
|
+
return None
|
|
997
|
+
return None # no convergence — the controller will try shorter
|
|
998
|
+
|
|
999
|
+
ts = [t0]
|
|
1000
|
+
rows = [[float(v) for v in y]]
|
|
1001
|
+
hist_t: list[float] = [t0] # last ≤ 3 accepted nodes
|
|
1002
|
+
hist_y: list[np.ndarray] = [y]
|
|
1003
|
+
t = t0
|
|
1004
|
+
steps = 0
|
|
1005
|
+
while (t1 - t) * sgn > 0:
|
|
1006
|
+
if steps >= max_steps:
|
|
1007
|
+
raise PycodemathError(
|
|
1008
|
+
f"{what}: exceeded {max_steps} steps at t={t:g} — "
|
|
1009
|
+
f"the solution is probably singular (narrow the interval)"
|
|
1010
|
+
)
|
|
1011
|
+
last = (t + h - t1) * sgn >= 0
|
|
1012
|
+
if last:
|
|
1013
|
+
h = t1 - t # the last step lands exactly on t1
|
|
1014
|
+
t_new = t + h
|
|
1015
|
+
u = err = None
|
|
1016
|
+
expo = 0.5
|
|
1017
|
+
if len(hist_t) == 1:
|
|
1018
|
+
# start-up 1: BDF1 full step vs two halves — the difference of
|
|
1019
|
+
# order-1 solutions estimates the O(h²) error; we carry the more accurate u
|
|
1020
|
+
u_full = newton(t_new, y, h, y)
|
|
1021
|
+
y_half = None if u_full is None else newton(t + h / 2, y, h / 2, y)
|
|
1022
|
+
u = None if y_half is None else newton(t_new, y_half, h / 2, y_half)
|
|
1023
|
+
if u is not None:
|
|
1024
|
+
err = u - u_full
|
|
1025
|
+
else:
|
|
1026
|
+
h_old = hist_t[-1] - hist_t[-2]
|
|
1027
|
+
w = h / h_old # step ratio ω > 0 (both carry the run's sign)
|
|
1028
|
+
a1 = (1.0 + w) ** 2 / (1.0 + 2.0 * w)
|
|
1029
|
+
a2 = -(w * w) / (1.0 + 2.0 * w)
|
|
1030
|
+
coef = h * (1.0 + w) / (1.0 + 2.0 * w)
|
|
1031
|
+
u = newton(t_new, a1 * hist_y[-1] + a2 * hist_y[-2], coef, hist_y[-1])
|
|
1032
|
+
if u is not None:
|
|
1033
|
+
if len(hist_t) >= 3:
|
|
1034
|
+
# full estimate: LTE = (1+ω)²/(ω(1+2ω))·h³·(divided difference)
|
|
1035
|
+
dd = _dd3([*hist_t[-3:], t_new], [*hist_y[-3:], u])
|
|
1036
|
+
err = ((1.0 + w) ** 2 / (w * (1.0 + 2.0 * w))) * h**3 * dd
|
|
1037
|
+
expo = 1.0 / 3.0
|
|
1038
|
+
else:
|
|
1039
|
+
# start-up 2: BDF1/BDF2 corrector pair — the difference estimates
|
|
1040
|
+
# the O(h²) error of the lower-order corrector (conservatively)
|
|
1041
|
+
u1 = newton(t_new, hist_y[-1], h, hist_y[-1])
|
|
1042
|
+
if u1 is None:
|
|
1043
|
+
u = None
|
|
1044
|
+
else:
|
|
1045
|
+
err = u - u1
|
|
1046
|
+
if u is None: # Newton/domain did not deliver — try shorter
|
|
1047
|
+
h *= 0.5
|
|
1048
|
+
if abs(h) < hmin:
|
|
1049
|
+
raise PycodemathError(
|
|
1050
|
+
f"{what}: step shrank below the limit at t={t:g} — "
|
|
1051
|
+
f"singularity (integration refuses to jump over it)"
|
|
1052
|
+
)
|
|
1053
|
+
continue
|
|
1054
|
+
sc = atol + rtol * np.maximum(np.abs(hist_y[-1]), np.abs(u))
|
|
1055
|
+
e = float(np.sqrt(np.mean((err / sc) ** 2))) # RMS of the normalized error
|
|
1056
|
+
if e <= 1.0: # --- step accepted ---
|
|
1057
|
+
t = t1 if last else t_new
|
|
1058
|
+
y = u
|
|
1059
|
+
ts.append(t)
|
|
1060
|
+
rows.append([float(v) for v in y])
|
|
1061
|
+
hist_t.append(t)
|
|
1062
|
+
hist_y.append(y)
|
|
1063
|
+
if len(hist_t) > 3:
|
|
1064
|
+
hist_t.pop(0)
|
|
1065
|
+
hist_y.pop(0)
|
|
1066
|
+
steps += 1
|
|
1067
|
+
# safety factor 0.9; growth ≤ 2x (zero-stability of BDF2: ω < 1+√2)
|
|
1068
|
+
grow = 2.0 if e == 0.0 else min(2.0, 0.9 * e**-expo)
|
|
1069
|
+
h *= grow
|
|
1070
|
+
else: # --- step rejected: reduce and repeat ---
|
|
1071
|
+
h *= max(0.2, 0.9 * e**-expo)
|
|
1072
|
+
if abs(h) < hmin:
|
|
1073
|
+
raise PycodemathError(
|
|
1074
|
+
f"{what}: step shrank below the limit at t={t:g} — "
|
|
1075
|
+
f"singularity (integration refuses to jump over it)"
|
|
1076
|
+
)
|
|
1077
|
+
return ts, rows
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def solve_ode_stiff_adaptive(
|
|
1081
|
+
rhs: Expr,
|
|
1082
|
+
var: str,
|
|
1083
|
+
y0: float,
|
|
1084
|
+
t_span: "Sequence[float]",
|
|
1085
|
+
rtol: float = 1e-6,
|
|
1086
|
+
atol: float = 1e-9,
|
|
1087
|
+
max_steps: int = 100_000,
|
|
1088
|
+
func: str = "y",
|
|
1089
|
+
) -> tuple[list[float], list[float]]:
|
|
1090
|
+
"""Stiff problem ``y' = f(var, y)`` — BDF2 with a VARIABLE step.
|
|
1091
|
+
|
|
1092
|
+
Combines the merits of ``solve_ode_stiff`` (an A-stable implicit method — the
|
|
1093
|
+
step is limited by accuracy, not stiffness) and ``solve_ode_adaptive`` (the step
|
|
1094
|
+
is chosen by the local error estimate to the tolerance ``atol + rtol·|y|``):
|
|
1095
|
+
on a stiff problem it reaches the tolerance with an order of magnitude fewer
|
|
1096
|
+
steps than DOPRI5, which stability forces into tiny steps. Details of the
|
|
1097
|
+
scheme (variable-step BDF2, estimate from the divided difference, start-up)
|
|
1098
|
+
— see ``_bdf_adaptive``.
|
|
1099
|
+
|
|
1100
|
+
``rhs`` depends at most on ``var`` and ``func``; ``∂f/∂y`` computed
|
|
1101
|
+
SYMBOLICALLY and compiled ONCE. Returns ``(ts, ys)`` at adaptively chosen
|
|
1102
|
+
nodes. Singularity / bad input -> ``PycodemathError``.
|
|
1103
|
+
"""
|
|
1104
|
+
r = Expr(rhs)
|
|
1105
|
+
_check_vars([r], [var, func], "solve_ode_stiff_adaptive")
|
|
1106
|
+
t0, t1 = _span(t_span, "solve_ode_stiff_adaptive")
|
|
1107
|
+
rtol, atol = _tols(rtol, atol, "solve_ode_stiff_adaptive")
|
|
1108
|
+
F_fn = _vector_fn([r], [var, func]) # compiled once — before the loop
|
|
1109
|
+
J_fn = _vector_fn([r.diff(func)], [var, func])
|
|
1110
|
+
ts, rows = _bdf_adaptive(
|
|
1111
|
+
F_fn, J_fn, 1, t0, t1, np.array([float(y0)], dtype=float),
|
|
1112
|
+
rtol, atol, int(max_steps), "solve_ode_stiff_adaptive",
|
|
1113
|
+
)
|
|
1114
|
+
return ts, [row[0] for row in rows]
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def solve_ode_system_stiff_adaptive(
|
|
1118
|
+
rhs: "Sequence[Expr]",
|
|
1119
|
+
var: str,
|
|
1120
|
+
funcs: "Sequence[str]",
|
|
1121
|
+
y0: "Sequence[float]",
|
|
1122
|
+
t_span: "Sequence[float]",
|
|
1123
|
+
rtol: float = 1e-6,
|
|
1124
|
+
atol: float = 1e-9,
|
|
1125
|
+
max_steps: int = 100_000,
|
|
1126
|
+
) -> tuple[list[float], list[list[float]]]:
|
|
1127
|
+
"""STIFF system ``y' = F(var, y)`` — BDF2 with a VARIABLE step (vectorized).
|
|
1128
|
+
|
|
1129
|
+
The vector counterpart of ``solve_ode_stiff_adaptive`` — the core
|
|
1130
|
+
``_bdf_adaptive`` is vectorized, Newton per step solves the system
|
|
1131
|
+
``(I − coef·J)·Δ = −R`` with the Jacobian ``∂F/∂y`` computed SYMBOLICALLY
|
|
1132
|
+
(the module-16 pattern). This is the right tool for classics of the
|
|
1133
|
+
Van der Pol kind with μ=1000, where a fixed step makes no economic sense
|
|
1134
|
+
(relaxation forces a tiny step only in places). Returns
|
|
1135
|
+
``(ts, rows)`` like ``solve_ode_system_num``.
|
|
1136
|
+
"""
|
|
1137
|
+
fs, funcs, y = _system_inputs(rhs, var, funcs, y0, "solve_ode_system_stiff_adaptive")
|
|
1138
|
+
t0, t1 = _span(t_span, "solve_ode_system_stiff_adaptive")
|
|
1139
|
+
rtol, atol = _tols(rtol, atol, "solve_ode_system_stiff_adaptive")
|
|
1140
|
+
F_fn = _vector_fn(fs, [var, *funcs]) # compiled once — before the loop
|
|
1141
|
+
J_fn = _vector_fn([f.diff(g) for f in fs for g in funcs], [var, *funcs])
|
|
1142
|
+
return _bdf_adaptive(
|
|
1143
|
+
F_fn, J_fn, len(funcs), t0, t1, y, rtol, atol, int(max_steps),
|
|
1144
|
+
"solve_ode_system_stiff_adaptive",
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# --- systems for dense/events (module 11) ---------------------------------
|
|
1149
|
+
class DenseSystemSolution(DenseSolution):
|
|
1150
|
+
"""DOPRI5 dense output for a SYSTEM — ``sol(t)`` returns the state VECTOR.
|
|
1151
|
+
|
|
1152
|
+
The core (segments, per-coordinate interpolant, interval bounds) is shared
|
|
1153
|
+
with the scalar ``DenseSolution`` — only the read shape differs:
|
|
1154
|
+
``sol(t)`` for a number gives an ``np.ndarray`` of length ``len(funcs)``
|
|
1155
|
+
(state in ``funcs`` order), for a sequence — an array ``(m, n)``
|
|
1156
|
+
(state-row per point). Nodes: ``.ts`` and ``.ys`` = list of states
|
|
1157
|
+
(``ys[k][i]`` = value of ``funcs[i]`` at ``ts[k]``, as in
|
|
1158
|
+
``solve_ode_system_num``).
|
|
1159
|
+
"""
|
|
1160
|
+
|
|
1161
|
+
def __init__(
|
|
1162
|
+
self, ts: list[float], rows: list[list[float]], segments: list,
|
|
1163
|
+
funcs: "Sequence[str]",
|
|
1164
|
+
):
|
|
1165
|
+
super().__init__(ts, rows, segments)
|
|
1166
|
+
self.funcs = list(funcs)
|
|
1167
|
+
self.ys = [list(row) for row in rows] # state (list) at each node
|
|
1168
|
+
|
|
1169
|
+
def __call__(self, t):
|
|
1170
|
+
"""Read the state — a vector for a number, an array ``(m, n)`` for a sequence."""
|
|
1171
|
+
if np.ndim(t) == 0:
|
|
1172
|
+
return self._eval_vec(float(t))
|
|
1173
|
+
return np.array([self._eval_vec(float(v)) for v in t])
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
class EventSystemSolution(DenseSystemSolution):
|
|
1177
|
+
"""``DenseSystemSolution`` + events ``g(t, y1..yn) = 0`` (like ``EventSolution``).
|
|
1178
|
+
|
|
1179
|
+
``.events`` = ``[(t, state), …]`` — the state is a list in ``funcs`` order;
|
|
1180
|
+
``.event_times`` / ``.event_values`` separated as in the scalar version.
|
|
1181
|
+
"""
|
|
1182
|
+
|
|
1183
|
+
def __init__(
|
|
1184
|
+
self, ts: list[float], rows: list[list[float]], segments: list,
|
|
1185
|
+
funcs: "Sequence[str]", events: "list[tuple[float, list[float]]]",
|
|
1186
|
+
):
|
|
1187
|
+
super().__init__(ts, rows, segments, funcs)
|
|
1188
|
+
self.events = events
|
|
1189
|
+
self.event_times = [t for t, _ in events]
|
|
1190
|
+
self.event_values = [y for _, y in events]
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def solve_ode_system_dense(
|
|
1194
|
+
rhs: "Sequence[Expr]",
|
|
1195
|
+
var: str,
|
|
1196
|
+
funcs: "Sequence[str]",
|
|
1197
|
+
y0: "Sequence[float]",
|
|
1198
|
+
t_span: "Sequence[float]",
|
|
1199
|
+
rtol: float = 1e-6,
|
|
1200
|
+
atol: float = 1e-9,
|
|
1201
|
+
max_steps: int = 100_000,
|
|
1202
|
+
) -> DenseSystemSolution:
|
|
1203
|
+
"""System ``y' = F(var, y)`` with DENSE OUTPUT — vectorized ``solve_ode_dense``.
|
|
1204
|
+
|
|
1205
|
+
The same DOPRI5 engine (the ``_dopri45`` core is vectorized since module 6);
|
|
1206
|
+
the 4th-order interpolant is computed per coordinate, so ``sol(t)`` reads the
|
|
1207
|
+
FULL state at any point of ``[t0, t1]``. ``rhs[i]`` is the right-hand side of
|
|
1208
|
+
the equation for ``funcs[i]``; the expressions depend at most on ``var``
|
|
1209
|
+
and ``funcs``. Singularity / bad input -> ``PycodemathError``.
|
|
1210
|
+
"""
|
|
1211
|
+
fs, funcs, y = _system_inputs(rhs, var, funcs, y0, "solve_ode_system_dense")
|
|
1212
|
+
t0, t1 = _span(t_span, "solve_ode_system_dense")
|
|
1213
|
+
rtol, atol = _tols(rtol, atol, "solve_ode_system_dense")
|
|
1214
|
+
sample = _vector_fn(fs, [var, *funcs]) # compiled once — before the loop
|
|
1215
|
+
segments: list = []
|
|
1216
|
+
ts, rows = _dopri45(
|
|
1217
|
+
sample, t0, t1, y, rtol, atol, int(max_steps),
|
|
1218
|
+
"solve_ode_system_dense", segments,
|
|
1219
|
+
)
|
|
1220
|
+
return DenseSystemSolution(ts, rows, segments, funcs)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def solve_ode_system_events(
|
|
1224
|
+
rhs: "Sequence[Expr]",
|
|
1225
|
+
var: str,
|
|
1226
|
+
funcs: "Sequence[str]",
|
|
1227
|
+
y0: "Sequence[float]",
|
|
1228
|
+
t_span: "Sequence[float]",
|
|
1229
|
+
event: Expr,
|
|
1230
|
+
direction: int = 0,
|
|
1231
|
+
terminal: bool = False,
|
|
1232
|
+
rtol: float = 1e-6,
|
|
1233
|
+
atol: float = 1e-9,
|
|
1234
|
+
max_steps: int = 100_000,
|
|
1235
|
+
) -> EventSystemSolution:
|
|
1236
|
+
"""System ``y' = F(var, y)`` + events ``g(var, y1..yn) = 0``.
|
|
1237
|
+
|
|
1238
|
+
Vectorized ``solve_ode_events``: the event function is ONE scalar
|
|
1239
|
+
depending on ``var`` and any of ``funcs`` (e.g. ``x`` in an oscillator —
|
|
1240
|
+
an event on a coordinate crossing zero). ``direction`` filters the
|
|
1241
|
+
transition direction; ``terminal=True`` stops integration at the first
|
|
1242
|
+
event (nodes and ``sol(t)`` end at ``t_event``) — the mechanics are
|
|
1243
|
+
identical to the scalar case (the same ``_terminal_stop`` and ``_find_events``).
|
|
1244
|
+
|
|
1245
|
+
Returns ``EventSystemSolution`` (callable, ``.events`` with the state as a list).
|
|
1246
|
+
"""
|
|
1247
|
+
fs, funcs, y = _system_inputs(rhs, var, funcs, y0, "solve_ode_system_events")
|
|
1248
|
+
g = Expr(event)
|
|
1249
|
+
_check_vars([g], [var, *funcs], "solve_ode_system_events (event function)")
|
|
1250
|
+
if direction not in (-1, 0, 1):
|
|
1251
|
+
raise PycodemathError(
|
|
1252
|
+
"solve_ode_system_events: direction must be -1, 0, or +1"
|
|
1253
|
+
)
|
|
1254
|
+
t0, t1 = _span(t_span, "solve_ode_system_events")
|
|
1255
|
+
rtol, atol = _tols(rtol, atol, "solve_ode_system_events")
|
|
1256
|
+
sample = _vector_fn(fs, [var, *funcs]) # compiled once — before the loop
|
|
1257
|
+
g_fn = _vector_fn([g], [var, *funcs]) # event function — compiled once
|
|
1258
|
+
segments: list = []
|
|
1259
|
+
|
|
1260
|
+
if terminal: # event checked DURING integration (stop on hit)
|
|
1261
|
+
stop = _terminal_stop(g_fn, int(direction), "solve_ode_system_events")
|
|
1262
|
+
ts, rows = _dopri45(
|
|
1263
|
+
sample, t0, t1, y, rtol, atol, int(max_steps),
|
|
1264
|
+
"solve_ode_system_events", segments, stop,
|
|
1265
|
+
)
|
|
1266
|
+
events: list[tuple[float, list[float]]] = []
|
|
1267
|
+
if stop.hit is not None: # type: ignore[attr-defined]
|
|
1268
|
+
t_ev, y_ev = stop.hit # type: ignore[attr-defined]
|
|
1269
|
+
events = [(float(t_ev), [float(v) for v in y_ev])]
|
|
1270
|
+
return EventSystemSolution(ts, rows, segments, funcs, events)
|
|
1271
|
+
|
|
1272
|
+
ts, rows = _dopri45(
|
|
1273
|
+
sample, t0, t1, y, rtol, atol, int(max_steps),
|
|
1274
|
+
"solve_ode_system_events", segments,
|
|
1275
|
+
)
|
|
1276
|
+
sol = DenseSystemSolution(ts, rows, segments, funcs)
|
|
1277
|
+
raw = _find_events(sol, g_fn, int(direction), "solve_ode_system_events")
|
|
1278
|
+
events = [(t, [float(v) for v in vec]) for t, vec in raw]
|
|
1279
|
+
return EventSystemSolution(ts, rows, segments, funcs, events)
|