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.
- odeanalysis/__init__.py +296 -0
- odeanalysis/_api_policy.py +179 -0
- odeanalysis/_assumptions.py +89 -0
- odeanalysis/_block_common.py +106 -0
- odeanalysis/_formal_gauge.py +115 -0
- odeanalysis/_local.py +166 -0
- odeanalysis/_moser.py +271 -0
- odeanalysis/_power_simplify.py +32 -0
- odeanalysis/_spectral.py +215 -0
- odeanalysis/_symbolic_compare.py +16 -0
- odeanalysis/_symbolic_errors.py +19 -0
- odeanalysis/_zero.py +28 -0
- odeanalysis/analytic_continuation.py +343 -0
- odeanalysis/bell.py +87 -0
- odeanalysis/block_decomposition.py +1144 -0
- odeanalysis/canonical.py +471 -0
- odeanalysis/certified_continuation.py +160 -0
- odeanalysis/diagnostics.py +17 -0
- odeanalysis/dominance.py +134 -0
- odeanalysis/factorization.py +88 -0
- odeanalysis/formal.py +1028 -0
- odeanalysis/formal_basis.py +1009 -0
- odeanalysis/frobenius.py +349 -0
- odeanalysis/fuchsian.py +400 -0
- odeanalysis/interchange.py +604 -0
- odeanalysis/interoperability.py +143 -0
- odeanalysis/irregular.py +250 -0
- odeanalysis/kovacic.py +478 -0
- odeanalysis/levelt.py +679 -0
- odeanalysis/local_analysis.py +386 -0
- odeanalysis/local_structure.py +290 -0
- odeanalysis/matrix_series.py +357 -0
- odeanalysis/newton.py +501 -0
- odeanalysis/operator.py +193 -0
- odeanalysis/parameter_wkb.py +92 -0
- odeanalysis/py.typed +0 -0
- odeanalysis/series.py +199 -0
- odeanalysis/singularities.py +279 -0
- odeanalysis/stokes.py +782 -0
- odeanalysis/system.py +347 -0
- odeanalysis/system_analysis.py +615 -0
- odeanalysis/transition_loci.py +302 -0
- odeanalysis/turning.py +516 -0
- odeanalysis/wronskian.py +110 -0
- odeanalysis-0.1.0.dist-info/METADATA +180 -0
- odeanalysis-0.1.0.dist-info/RECORD +49 -0
- odeanalysis-0.1.0.dist-info/WHEEL +5 -0
- odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
- odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
odeanalysis/canonical.py
ADDED
|
@@ -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
|