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.
@@ -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)