odeanalysis 0.1.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.
Files changed (49) hide show
  1. odeanalysis/__init__.py +296 -0
  2. odeanalysis/_api_policy.py +179 -0
  3. odeanalysis/_assumptions.py +89 -0
  4. odeanalysis/_block_common.py +106 -0
  5. odeanalysis/_formal_gauge.py +115 -0
  6. odeanalysis/_local.py +166 -0
  7. odeanalysis/_moser.py +271 -0
  8. odeanalysis/_power_simplify.py +32 -0
  9. odeanalysis/_spectral.py +215 -0
  10. odeanalysis/_symbolic_compare.py +16 -0
  11. odeanalysis/_symbolic_errors.py +19 -0
  12. odeanalysis/_zero.py +28 -0
  13. odeanalysis/analytic_continuation.py +343 -0
  14. odeanalysis/bell.py +87 -0
  15. odeanalysis/block_decomposition.py +1144 -0
  16. odeanalysis/canonical.py +471 -0
  17. odeanalysis/certified_continuation.py +160 -0
  18. odeanalysis/diagnostics.py +17 -0
  19. odeanalysis/dominance.py +134 -0
  20. odeanalysis/factorization.py +88 -0
  21. odeanalysis/formal.py +1028 -0
  22. odeanalysis/formal_basis.py +1009 -0
  23. odeanalysis/frobenius.py +349 -0
  24. odeanalysis/fuchsian.py +400 -0
  25. odeanalysis/interchange.py +604 -0
  26. odeanalysis/interoperability.py +143 -0
  27. odeanalysis/irregular.py +250 -0
  28. odeanalysis/kovacic.py +478 -0
  29. odeanalysis/levelt.py +679 -0
  30. odeanalysis/local_analysis.py +386 -0
  31. odeanalysis/local_structure.py +290 -0
  32. odeanalysis/matrix_series.py +357 -0
  33. odeanalysis/newton.py +501 -0
  34. odeanalysis/operator.py +193 -0
  35. odeanalysis/parameter_wkb.py +92 -0
  36. odeanalysis/py.typed +0 -0
  37. odeanalysis/series.py +199 -0
  38. odeanalysis/singularities.py +279 -0
  39. odeanalysis/stokes.py +782 -0
  40. odeanalysis/system.py +347 -0
  41. odeanalysis/system_analysis.py +615 -0
  42. odeanalysis/transition_loci.py +302 -0
  43. odeanalysis/turning.py +516 -0
  44. odeanalysis/wronskian.py +110 -0
  45. odeanalysis-0.1.0.dist-info/METADATA +180 -0
  46. odeanalysis-0.1.0.dist-info/RECORD +49 -0
  47. odeanalysis-0.1.0.dist-info/WHEEL +5 -0
  48. odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
  49. odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,471 @@
1
+ """Recognition of classical second-order canonical equations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from itertools import permutations
8
+
9
+ import sympy as sp
10
+
11
+ from .fuchsian import _complete_exponents
12
+ from .operator import LinearDifferentialOperator, _coerce_linear_operator
13
+ from .singularities import ODESingularityKind, analyze_ode_singularities
14
+
15
+
16
+ class CanonicalEquationFamily(Enum):
17
+ """Classical second-order equation families recognized by ``odeanalysis``."""
18
+
19
+ AIRY = "airy"
20
+ EULER = "euler"
21
+ BESSEL = "bessel"
22
+ MODIFIED_BESSEL = "modified_bessel"
23
+ HYPERGEOMETRIC = "hypergeometric"
24
+ CONFLUENT_HYPERGEOMETRIC = "confluent_hypergeometric"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CanonicalEquationRecognition:
29
+ """An exact recognition certificate for a classical canonical equation.
30
+
31
+ ``variable_transform`` is the projective change ``z=z(x)`` and
32
+ ``dependent_gauge`` records ``y(x)=g(x)u(z(x))``. The logarithmic
33
+ derivative of the gauge is stored separately so verification does not
34
+ depend on branch-sensitive simplification of symbolic powers.
35
+ """
36
+
37
+ family: CanonicalEquationFamily
38
+ operator: LinearDifferentialOperator
39
+ canonical_variable: sp.Symbol
40
+ variable_transform: sp.Expr
41
+ mobius_coefficients: tuple[sp.Expr, sp.Expr, sp.Expr, sp.Expr]
42
+ dependent_gauge: sp.Expr
43
+ gauge_log_derivative: sp.Expr
44
+ parameters: tuple[tuple[str, sp.Expr], ...]
45
+ canonical_expression: sp.Expr
46
+
47
+ @property
48
+ def parameter_map(self) -> dict[str, sp.Expr]:
49
+ """Return canonical parameters as a fresh mapping."""
50
+
51
+ return dict(self.parameters)
52
+
53
+ @property
54
+ def scale(self) -> sp.Expr | None:
55
+ """Return the affine scale when the projective map is affine."""
56
+
57
+ a, _, c, d = self.mobius_coefficients
58
+ if sp.simplify(c) != 0:
59
+ return None
60
+ return sp.simplify(a / d)
61
+
62
+ @property
63
+ def shift(self) -> sp.Expr | None:
64
+ """Return the affine shift when the projective map is affine."""
65
+
66
+ _, b, c, d = self.mobius_coefficients
67
+ if sp.simplify(c) != 0:
68
+ return None
69
+ return sp.simplify(b / d)
70
+
71
+ @property
72
+ def is_affine(self) -> bool:
73
+ """Whether the independent-variable transformation is affine."""
74
+
75
+ return sp.simplify(self.mobius_coefficients[2]) == 0
76
+
77
+ def verify(self) -> bool:
78
+ """Replay the projective pullback and gauge transformation exactly."""
79
+
80
+ x = self.operator.variable
81
+ z = self.canonical_variable
82
+ u = sp.Function("_odeanalysis_canonical_u")
83
+ canonical = LinearDifferentialOperator.from_ode(
84
+ self.canonical_expression, u, z
85
+ ).normalized()
86
+ target = self.operator.normalized()
87
+ z_of_x = self.variable_transform
88
+ z_prime = sp.diff(z_of_x, x)
89
+ if sp.simplify(z_prime) == 0:
90
+ return False
91
+ z_second = sp.diff(z_prime, x)
92
+ h = self.gauge_log_derivative
93
+ canonical_p = canonical.coefficients[1].subs(z, z_of_x)
94
+ canonical_q = canonical.coefficients[0].subs(z, z_of_x)
95
+ expected_p = sp.cancel(z_prime * canonical_p - 2 * h - z_second / z_prime)
96
+ expected_q = sp.cancel(
97
+ z_prime**2 * canonical_q - expected_p * h - sp.diff(h, x) - h**2
98
+ )
99
+ expected = (expected_q, expected_p, sp.S.One)
100
+ return all(
101
+ sp.simplify(sp.cancel(a - b)) == 0
102
+ for a, b in zip(expected, target.coefficients, strict=True)
103
+ )
104
+
105
+
106
+ def _canonical_expression(
107
+ family: CanonicalEquationFamily,
108
+ z: sp.Symbol,
109
+ params: dict[str, sp.Expr],
110
+ ) -> sp.Expr:
111
+ u = sp.Function("_odeanalysis_canonical_u")
112
+ uz = u(z)
113
+ if family is CanonicalEquationFamily.AIRY:
114
+ return sp.diff(uz, z, 2) - z * uz
115
+ if family is CanonicalEquationFamily.EULER:
116
+ alpha, beta = params["alpha"], params["beta"]
117
+ return z**2 * sp.diff(uz, z, 2) + alpha * z * sp.diff(uz, z) + beta * uz
118
+ if family is CanonicalEquationFamily.BESSEL:
119
+ nu = params["nu"]
120
+ return z**2 * sp.diff(uz, z, 2) + z * sp.diff(uz, z) + (z**2 - nu**2) * uz
121
+ if family is CanonicalEquationFamily.MODIFIED_BESSEL:
122
+ nu = params["nu"]
123
+ return z**2 * sp.diff(uz, z, 2) + z * sp.diff(uz, z) - (z**2 + nu**2) * uz
124
+ if family is CanonicalEquationFamily.HYPERGEOMETRIC:
125
+ a, b, c = params["a"], params["b"], params["c"]
126
+ return (
127
+ z * (1 - z) * sp.diff(uz, z, 2)
128
+ + (c - (a + b + 1) * z) * sp.diff(uz, z)
129
+ - a * b * uz
130
+ )
131
+ if family is CanonicalEquationFamily.CONFLUENT_HYPERGEOMETRIC:
132
+ a, c = params["a"], params["c"]
133
+ return z * sp.diff(uz, z, 2) + (c - z) * sp.diff(uz, z) - a * uz
134
+ raise ValueError(f"unsupported canonical family: {family}")
135
+
136
+
137
+ def _projective_recognition(
138
+ family: CanonicalEquationFamily,
139
+ op: LinearDifferentialOperator,
140
+ mobius: tuple[sp.Expr, sp.Expr, sp.Expr, sp.Expr],
141
+ params: dict[str, sp.Expr],
142
+ *,
143
+ dependent_gauge: sp.Expr = sp.S.One,
144
+ gauge_log_derivative: sp.Expr = sp.S.Zero,
145
+ ) -> CanonicalEquationRecognition:
146
+ x = op.variable
147
+ a, b, c, d = map(sp.simplify, mobius)
148
+ determinant = sp.simplify(a * d - b * c)
149
+ if determinant == 0:
150
+ raise ValueError("Möbius transformation must have nonzero determinant")
151
+ z_of_x = sp.cancel((a * x + b) / (c * x + d))
152
+ z = sp.Symbol("z")
153
+ return CanonicalEquationRecognition(
154
+ family=family,
155
+ operator=op,
156
+ canonical_variable=z,
157
+ variable_transform=z_of_x,
158
+ mobius_coefficients=(a, b, c, d),
159
+ dependent_gauge=sp.simplify(dependent_gauge),
160
+ gauge_log_derivative=sp.cancel(gauge_log_derivative),
161
+ parameters=tuple(sorted(params.items())),
162
+ canonical_expression=_canonical_expression(family, z, params),
163
+ )
164
+
165
+
166
+ def _recognition(
167
+ family: CanonicalEquationFamily,
168
+ op: LinearDifferentialOperator,
169
+ scale: sp.Expr,
170
+ shift: sp.Expr,
171
+ params: dict[str, sp.Expr],
172
+ ) -> CanonicalEquationRecognition:
173
+ return _projective_recognition(
174
+ family,
175
+ op,
176
+ (scale, shift, sp.S.Zero, sp.S.One),
177
+ params,
178
+ )
179
+
180
+
181
+ def _recognize_euler(
182
+ op: LinearDifferentialOperator,
183
+ ) -> CanonicalEquationRecognition | None:
184
+ """Recognize an exact Euler--Cauchy equation at the origin."""
185
+ x = op.variable
186
+ normalized = op.normalized()
187
+ p, q = normalized.coefficients[1], normalized.coefficients[0]
188
+ alpha = sp.cancel(x * p)
189
+ beta = sp.cancel(x**2 * q)
190
+ if alpha.has(x) or beta.has(x):
191
+ return None
192
+ result = _recognition(
193
+ CanonicalEquationFamily.EULER,
194
+ op,
195
+ sp.S.One,
196
+ sp.S.Zero,
197
+ {"alpha": alpha, "beta": beta},
198
+ )
199
+ return result if result.verify() else None
200
+
201
+
202
+ def _recognize_airy(
203
+ op: LinearDifferentialOperator,
204
+ ) -> CanonicalEquationRecognition | None:
205
+ x = op.variable
206
+ normalized = op.normalized()
207
+ p, q = normalized.coefficients[1], normalized.coefficients[0]
208
+ if sp.simplify(p) != 0:
209
+ return None
210
+ try:
211
+ poly = sp.Poly(sp.cancel(q), x)
212
+ except sp.PolynomialError:
213
+ return None
214
+ if poly.degree() != 1:
215
+ return None
216
+ slope = poly.coeff_monomial(x)
217
+ intercept = poly.coeff_monomial(1)
218
+ scale = (
219
+ sp.real_root(-slope, 3)
220
+ if slope.is_real is True
221
+ else (-slope) ** sp.Rational(1, 3)
222
+ )
223
+ if sp.simplify(scale) == 0:
224
+ return None
225
+ shift = sp.simplify(-intercept / scale**2)
226
+ result = _recognition(CanonicalEquationFamily.AIRY, op, scale, shift, {})
227
+ return result if result.verify() else None
228
+
229
+
230
+ def _bessel_candidate(
231
+ op: LinearDifferentialOperator, modified: bool
232
+ ) -> CanonicalEquationRecognition | None:
233
+ x = op.variable
234
+ normalized = op.normalized()
235
+ p, q = map(sp.cancel, (normalized.coefficients[1], normalized.coefficients[0]))
236
+ # p = 1/(x-x0) fixes the affine shift exactly.
237
+ numerator, denominator = sp.fraction(sp.cancel(1 / p)) if p != 0 else (0, 1)
238
+ try:
239
+ affine = sp.Poly(sp.cancel(numerator / denominator), x)
240
+ except sp.PolynomialError:
241
+ return None
242
+ if affine.degree() != 1 or sp.simplify(affine.coeff_monomial(x) - 1) != 0:
243
+ return None
244
+ x0 = sp.simplify(-affine.coeff_monomial(1))
245
+ t = sp.expand(x - x0)
246
+ if sp.simplify(p - 1 / t) != 0:
247
+ return None
248
+ constant = sp.simplify(sp.limit(q, x, sp.oo))
249
+ if modified and constant.is_positive is True:
250
+ return None
251
+ if not modified and constant.is_negative is True:
252
+ return None
253
+ scale_sq = -constant if modified else constant
254
+ if sp.simplify(scale_sq) == 0:
255
+ return None
256
+ nu_sq = sp.simplify(t**2 * ((-scale_sq if modified else scale_sq) - q))
257
+ if x in nu_sq.free_symbols:
258
+ return None
259
+ scale = sp.sqrt(scale_sq)
260
+ nu = sp.sqrt(nu_sq)
261
+ family = (
262
+ CanonicalEquationFamily.MODIFIED_BESSEL
263
+ if modified
264
+ else CanonicalEquationFamily.BESSEL
265
+ )
266
+ result = _recognition(family, op, scale, sp.simplify(-scale * x0), {"nu": nu})
267
+ return result if result.verify() else None
268
+
269
+
270
+ def _recognize_confluent(
271
+ op: LinearDifferentialOperator,
272
+ ) -> CanonicalEquationRecognition | None:
273
+ x = op.variable
274
+ p = sp.cancel(op.normalized().coefficients[1])
275
+ q = sp.cancel(op.normalized().coefficients[0])
276
+ # After z=s(x-x0): p = c/(x-x0)-s and q = -a*s/(x-x0).
277
+ den = sp.denom(sp.together(p))
278
+ roots = sp.solve(den, x)
279
+ if len(roots) != 1:
280
+ return None
281
+ x0 = roots[0]
282
+ t = x - x0
283
+ scale = sp.simplify(-sp.limit(p, x, sp.oo))
284
+ if sp.simplify(scale) == 0:
285
+ return None
286
+ c = sp.simplify(sp.limit(t * (p + scale), x, x0))
287
+ a = sp.simplify(-t * q / scale)
288
+ if (
289
+ x in a.free_symbols
290
+ or sp.simplify(p - (c / t - scale)) != 0
291
+ or sp.simplify(q + a * scale / t) != 0
292
+ ):
293
+ return None
294
+ result = _recognition(
295
+ CanonicalEquationFamily.CONFLUENT_HYPERGEOMETRIC,
296
+ op,
297
+ scale,
298
+ sp.simplify(-scale * x0),
299
+ {"a": a, "c": c},
300
+ )
301
+ return result if result.verify() else None
302
+
303
+
304
+ def _recognize_hypergeometric(
305
+ op: LinearDifferentialOperator,
306
+ ) -> CanonicalEquationRecognition | None:
307
+ x = op.variable
308
+ normalized = op.normalized()
309
+ p, q = map(sp.cancel, (normalized.coefficients[1], normalized.coefficients[0]))
310
+ # Affine pullbacks have exactly two finite poles x0,x1. Map them to 0,1.
311
+ den = sp.lcm(sp.denom(sp.together(p)), sp.denom(sp.together(q)))
312
+ roots = sp.solve(den, x)
313
+ if len(roots) != 2:
314
+ return None
315
+ for x0, x1 in (roots, roots[::-1]):
316
+ delta = sp.simplify(x1 - x0)
317
+ if sp.simplify(delta) == 0:
318
+ continue
319
+ scale = sp.simplify(1 / delta)
320
+ z = sp.simplify((x - x0) / delta)
321
+ # p = scale * [c-(a+b+1)z]/[z(1-z)]
322
+ # q = -scale^2*a*b/[z(1-z)].
323
+ c = sp.simplify(sp.limit(z * p / scale, x, x0))
324
+ sum_ab = sp.simplify(c - 1 - sp.cancel(p * z * (1 - z) / scale).coeff(z, 1))
325
+ # coeff(z,1) is unreliable because z is an expression; infer using endpoint residue.
326
+ res1 = sp.simplify(sp.limit((1 - z) * p / scale, x, x1))
327
+ sum_ab = sp.simplify(c - res1 - 1)
328
+ product = sp.simplify(-q * z * (1 - z) / scale**2)
329
+ if x in product.free_symbols:
330
+ continue
331
+ disc = sp.simplify(sum_ab**2 - 4 * product)
332
+ a = sp.simplify((sum_ab + sp.sqrt(disc)) / 2)
333
+ b = sp.simplify((sum_ab - sp.sqrt(disc)) / 2)
334
+ expected_p = sp.cancel(scale * (c - (a + b + 1) * z) / (z * (1 - z)))
335
+ expected_q = sp.cancel(-(scale**2) * a * b / (z * (1 - z)))
336
+ if sp.simplify(p - expected_p) != 0 or sp.simplify(q - expected_q) != 0:
337
+ continue
338
+ result = _recognition(
339
+ CanonicalEquationFamily.HYPERGEOMETRIC,
340
+ op,
341
+ scale,
342
+ sp.simplify(-scale * x0),
343
+ {"a": a, "b": b, "c": c},
344
+ )
345
+ if result.verify():
346
+ return result
347
+ return None
348
+
349
+
350
+ def _mobius_to_zero_one_infinity(
351
+ x: sp.Symbol,
352
+ p0: sp.Expr,
353
+ p1: sp.Expr,
354
+ pinf: sp.Expr,
355
+ ) -> tuple[sp.Expr, sp.Expr, sp.Expr, sp.Expr]:
356
+ """Return a projective map sending ``p0,p1,pinf`` to ``0,1,oo``."""
357
+
358
+ if pinf == sp.oo:
359
+ delta = sp.simplify(p1 - p0)
360
+ return (sp.simplify(1 / delta), sp.simplify(-p0 / delta), sp.S.Zero, sp.S.One)
361
+ if p0 == sp.oo:
362
+ return (sp.S.Zero, sp.simplify(p1 - pinf), sp.S.One, sp.simplify(-pinf))
363
+ if p1 == sp.oo:
364
+ return (sp.S.One, sp.simplify(-p0), sp.S.One, sp.simplify(-pinf))
365
+ scale = sp.simplify((p1 - pinf) / (p1 - p0))
366
+ return (scale, sp.simplify(-scale * p0), sp.S.One, sp.simplify(-pinf))
367
+
368
+
369
+ def _regular_singularity_exponents(
370
+ op: LinearDifferentialOperator,
371
+ ) -> tuple[tuple[sp.Expr, tuple[sp.Expr, ...]], ...] | None:
372
+ analysis = analyze_ode_singularities(op, include_infinity=True)
373
+ singularities = list(analysis.finite)
374
+ if (
375
+ analysis.infinity is not None
376
+ and analysis.infinity.kind is not ODESingularityKind.ORDINARY
377
+ ):
378
+ singularities.append(analysis.infinity)
379
+ if len(singularities) != 3:
380
+ return None
381
+ result: list[tuple[sp.Expr, tuple[sp.Expr, ...]]] = []
382
+ for singularity in singularities:
383
+ if singularity.kind is not ODESingularityKind.REGULAR:
384
+ return None
385
+ exponents = _complete_exponents(singularity)
386
+ if exponents is None or len(exponents) != 2:
387
+ return None
388
+ result.append((singularity.point, exponents))
389
+ return tuple(result)
390
+
391
+
392
+ def _recognize_projective_hypergeometric(
393
+ op: LinearDifferentialOperator,
394
+ ) -> CanonicalEquationRecognition | None:
395
+ """Recognize a general three-regular-singularity Riemann P equation."""
396
+
397
+ singularities = _regular_singularity_exponents(op)
398
+ if singularities is None:
399
+ return None
400
+ x = op.variable
401
+
402
+ for ordered in permutations(singularities):
403
+ (p0, exp0), (p1, exp1), (pinf, expinf) = ordered
404
+ try:
405
+ mobius = _mobius_to_zero_one_infinity(x, p0, p1, pinf)
406
+ except (TypeError, ValueError, ZeroDivisionError):
407
+ continue
408
+ a_m, b_m, c_m, d_m = mobius
409
+ z_of_x = sp.cancel((a_m * x + b_m) / (c_m * x + d_m))
410
+ z_prime = sp.diff(z_of_x, x)
411
+ for alpha in exp0:
412
+ other0 = exp0[1] if alpha == exp0[0] else exp0[0]
413
+ c_param = sp.simplify(1 + alpha - other0)
414
+ for beta in exp1:
415
+ other1 = exp1[1] if beta == exp1[0] else exp1[0]
416
+ a_param = sp.simplify(expinf[0] + alpha + beta)
417
+ b_param = sp.simplify(expinf[1] + alpha + beta)
418
+ exponent_gap = sp.simplify(c_param - a_param - b_param)
419
+ if sp.simplify(other1 - beta - exponent_gap) != 0:
420
+ continue
421
+ h = sp.cancel(alpha * z_prime / z_of_x - beta * z_prime / (1 - z_of_x))
422
+ gauge = sp.simplify(z_of_x**alpha * (1 - z_of_x) ** beta)
423
+ result = _projective_recognition(
424
+ CanonicalEquationFamily.HYPERGEOMETRIC,
425
+ op,
426
+ mobius,
427
+ {"a": a_param, "b": b_param, "c": c_param},
428
+ dependent_gauge=gauge,
429
+ gauge_log_derivative=h,
430
+ )
431
+ if result.verify():
432
+ return result
433
+ return None
434
+
435
+
436
+ def recognize_canonical_equation(
437
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
438
+ function: sp.FunctionClass | sp.Expr | None = None,
439
+ variable: sp.Symbol | None = None,
440
+ ) -> CanonicalEquationRecognition | None:
441
+ """Recognize an exact classical equation under affine or projective/gauge pullback."""
442
+
443
+ op = _coerce_linear_operator(ode, function, variable)
444
+ if not op.is_homogeneous or op.order != 2:
445
+ return None
446
+ for recognizer in (
447
+ _recognize_euler,
448
+ _recognize_airy,
449
+ lambda candidate: _bessel_candidate(candidate, False),
450
+ lambda candidate: _bessel_candidate(candidate, True),
451
+ _recognize_hypergeometric,
452
+ _recognize_confluent,
453
+ _recognize_projective_hypergeometric,
454
+ ):
455
+ result = recognizer(op)
456
+ if result is not None:
457
+ return result
458
+ return None
459
+
460
+
461
+ def transform_to_canonical(
462
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
463
+ function: sp.FunctionClass | sp.Expr | None = None,
464
+ variable: sp.Symbol | None = None,
465
+ ) -> CanonicalEquationRecognition:
466
+ """Return a verified canonical transformation or raise ``ValueError``."""
467
+
468
+ result = recognize_canonical_equation(ode, function, variable)
469
+ if result is None:
470
+ raise ValueError("equation is not in a recognized classical canonical family")
471
+ return result
@@ -0,0 +1,160 @@
1
+ """Validated numerical continuation with complex ball arithmetic.
2
+
3
+ The initial certified backend handles homogeneous constant-coefficient systems
4
+ exactly along a straight segment. It uses python-flint/Arb matrix exponential,
5
+ so every returned entry is a complex ball enclosure rather than a tolerance-only
6
+ floating-point estimate. Variable-coefficient validated integration remains an
7
+ explicitly unsupported extension point.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from threading import RLock
14
+
15
+ import sympy as sp
16
+
17
+ from .system import FirstOrderSystem
18
+
19
+ _ARB_CONTEXT_LOCK = RLock()
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CertifiedMatrixEnclosure:
24
+ """Serializable complex-ball enclosure for a transported fundamental matrix."""
25
+
26
+ rows: tuple[tuple[str, ...], ...]
27
+ precision_bits: int
28
+ method: str
29
+ certified: bool = True
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class CertifiedContinuationResult:
34
+ start: sp.Expr
35
+ end: sp.Expr
36
+ enclosure: CertifiedMatrixEnclosure | None
37
+ complete: bool
38
+ limitation: str | None = None
39
+
40
+
41
+ def _exact_acb(expr: sp.Expr):
42
+ """Convert a supported exact SymPy expression to an Arb complex ball.
43
+
44
+ Certification must never pass a rounded decimal approximation to Arb as
45
+ though it were the exact input. The converter therefore builds balls only
46
+ from exact rational arithmetic and rigorously evaluated named constants.
47
+ Unsupported exact constants/functions are refused conservatively.
48
+ """
49
+
50
+ from flint import acb, arb
51
+
52
+ expr = sp.sympify(expr)
53
+ if expr.free_symbols:
54
+ raise ValueError(
55
+ "certified continuation requires numeric endpoints and coefficients"
56
+ )
57
+ if expr.is_Integer:
58
+ return acb(int(expr))
59
+ if expr.is_Rational:
60
+ return acb(int(expr.p)) / int(expr.q)
61
+ if expr == sp.I:
62
+ return acb(0, 1)
63
+ if expr == sp.pi:
64
+ return acb(arb.pi())
65
+ if expr.is_Add:
66
+ total = acb(0)
67
+ for term in expr.args:
68
+ total += _exact_acb(term)
69
+ return total
70
+ if expr.is_Mul:
71
+ total = acb(1)
72
+ for factor in expr.args:
73
+ total *= _exact_acb(factor)
74
+ return total
75
+ if expr.is_Pow and expr.exp.is_Integer:
76
+ return _exact_acb(expr.base) ** int(expr.exp)
77
+ raise ValueError(
78
+ f"exact Arb conversion is not implemented for {expr!s}; "
79
+ "refusing to certify a rounded decimal surrogate"
80
+ )
81
+
82
+
83
+ def certified_system_continuation(
84
+ system: FirstOrderSystem,
85
+ start: sp.Expr,
86
+ end: sp.Expr,
87
+ *,
88
+ precision_bits: int = 160,
89
+ ) -> CertifiedContinuationResult:
90
+ """Enclose the fundamental transport for a constant homogeneous system.
91
+
92
+ For ``Y'=A Y`` with constant ``A``, transport from ``a`` to ``b`` is
93
+ ``exp((b-a) A)``. Arb's complex-ball matrix exponential encloses this
94
+ quantity rigorously. Variable coefficients are rejected rather than
95
+ treated with an uncertified floating-point ODE solver.
96
+ """
97
+ start = sp.sympify(start)
98
+ end = sp.sympify(end)
99
+ if precision_bits < 64:
100
+ raise ValueError("precision_bits must be at least 64")
101
+ if not system.is_homogeneous:
102
+ return CertifiedContinuationResult(
103
+ start,
104
+ end,
105
+ None,
106
+ False,
107
+ "inhomogeneous systems are not supported by the certified fundamental-matrix backend",
108
+ )
109
+ if any(sp.sympify(entry).has(system.variable) for entry in system.matrix):
110
+ return CertifiedContinuationResult(
111
+ start,
112
+ end,
113
+ None,
114
+ False,
115
+ "validated variable-coefficient integration is not implemented",
116
+ )
117
+ try:
118
+ from flint import acb_mat, ctx
119
+ except ImportError:
120
+ return CertifiedContinuationResult(
121
+ start,
122
+ end,
123
+ None,
124
+ False,
125
+ "python-flint is required for certified ball arithmetic",
126
+ )
127
+ # python-flint's context precision is process-global. Serialize context
128
+ # mutation so concurrent certified calls cannot silently change one
129
+ # another's working precision.
130
+ with _ARB_CONTEXT_LOCK:
131
+ old_prec = ctx.prec
132
+ try:
133
+ ctx.prec = precision_bits
134
+ delta = _exact_acb(end - start)
135
+ matrix = acb_mat(
136
+ [
137
+ [
138
+ _exact_acb(system.matrix[i, j]) * delta
139
+ for j in range(system.dimension)
140
+ ]
141
+ for i in range(system.dimension)
142
+ ]
143
+ )
144
+ transport = matrix.exp()
145
+ rows = tuple(
146
+ tuple(str(transport[i, j]) for j in range(system.dimension))
147
+ for i in range(system.dimension)
148
+ )
149
+ except (ValueError, TypeError, ArithmeticError) as exc:
150
+ return CertifiedContinuationResult(
151
+ start, end, None, False, f"ball continuation failed: {exc}"
152
+ )
153
+ finally:
154
+ ctx.prec = old_prec
155
+ return CertifiedContinuationResult(
156
+ start,
157
+ end,
158
+ CertifiedMatrixEnclosure(rows, precision_bits, "arb-matrix-exponential"),
159
+ True,
160
+ )
@@ -0,0 +1,17 @@
1
+ """Structured diagnostics for incomplete formal ODE reductions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ReductionDiagnostic:
10
+ """One exact obstruction or retained resonance encountered during reduction."""
11
+
12
+ stage: str
13
+ code: str
14
+ message: str
15
+ power: int | None = None
16
+ rank: int | None = None
17
+ order: int | None = None