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
odeanalysis/kovacic.py ADDED
@@ -0,0 +1,478 @@
1
+ """Kovacic Liouvillian analysis for rational second-order equations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from itertools import product
8
+ from math import factorial
9
+
10
+ import sympy as sp
11
+
12
+ from .factorization import FirstOrderFactorization, factor_differential_operator
13
+ from .operator import LinearDifferentialOperator, _coerce_linear_operator
14
+
15
+
16
+ class KovacicOutcome(Enum):
17
+ """Outcome of the Kovacic decision procedure."""
18
+
19
+ CASE_1 = "case_1"
20
+ CASE_2 = "case_2"
21
+ CASE_3 = "case_3"
22
+ NO_LIOUVILLIAN_SOLUTION = "no_liouvillian_solution"
23
+ UNDECIDED = "undecided"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class KovacicCase2Certificate:
28
+ """Polynomial and Riccati evidence for Kovacic Case 2."""
29
+
30
+ degree: int
31
+ theta: sp.Expr
32
+ polynomial: sp.Expr
33
+ logarithmic_derivatives: tuple[sp.Expr, ...]
34
+
35
+ def verify(self, r: sp.Expr, x: sp.Symbol) -> bool:
36
+ """Replay the Case-2 auxiliary equation and Riccati identities."""
37
+
38
+ p = self.polynomial
39
+ theta = self.theta
40
+ auxiliary = (
41
+ sp.diff(p, x, 3)
42
+ + 3 * theta * sp.diff(p, x, 2)
43
+ + (3 * theta**2 + 3 * sp.diff(theta, x) - 4 * r) * sp.diff(p, x)
44
+ + (
45
+ sp.diff(theta, x, 2)
46
+ + 3 * theta * sp.diff(theta, x)
47
+ + theta**3
48
+ - 4 * r * theta
49
+ - 2 * sp.diff(r, x)
50
+ )
51
+ * p
52
+ )
53
+ if sp.simplify(auxiliary) != 0:
54
+ return False
55
+ return bool(self.logarithmic_derivatives) and all(
56
+ sp.simplify(sp.diff(w, x) + w**2 - r) == 0
57
+ for w in self.logarithmic_derivatives
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class KovacicCase3Certificate:
63
+ """Polynomial recurrence evidence for Kovacic Case 3."""
64
+
65
+ n: int
66
+ degree: int
67
+ theta: sp.Expr
68
+ pole_polynomial: sp.Expr
69
+ polynomial: sp.Expr
70
+ recurrence: tuple[sp.Expr, ...]
71
+ algebraic_log_derivative: sp.Expr
72
+
73
+ def verify(self, r: sp.Expr, x: sp.Symbol) -> bool:
74
+ """Replay the Case-3 recurrence and terminal polynomial identity."""
75
+
76
+ n = self.n
77
+ s = self.pole_polynomial
78
+ theta = self.theta
79
+ # Stored order is P_n, P_{n-1}, ..., P_-1.
80
+ values = self.recurrence
81
+ if len(values) != n + 2 or sp.simplify(values[0] + self.polynomial) != 0:
82
+ return False
83
+ p_by_index = {n - offset: value for offset, value in enumerate(values)}
84
+ for i in range(n, -1, -1):
85
+ pi = p_by_index[i]
86
+ pnext = p_by_index.get(i + 1, sp.S.Zero)
87
+ expected = (
88
+ -s * sp.diff(pi, x)
89
+ + ((n - i) * sp.diff(s, x) - s * theta) * pi
90
+ - (n - i) * (i + 1) * s**2 * r * pnext
91
+ )
92
+ if sp.simplify(p_by_index[i - 1] - expected) != 0:
93
+ return False
94
+ if sp.simplify(p_by_index[-1]) != 0:
95
+ return False
96
+ omega = sp.Symbol("omega")
97
+ expected_poly = sp.expand(
98
+ sum(
99
+ s**i * p_by_index[i] * omega**i / factorial(n - i) for i in range(n + 1)
100
+ )
101
+ )
102
+ return sp.simplify(expected_poly - self.algebraic_log_derivative) == 0
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class KovacicAnalysis:
107
+ """Certificate-oriented result of Kovacic's algorithm."""
108
+
109
+ operator: LinearDifferentialOperator
110
+ normal_form_potential: sp.Expr
111
+ outcome: KovacicOutcome
112
+ case: int | None
113
+ factorizations: tuple[FirstOrderFactorization, ...]
114
+ finite_pole_orders: tuple[tuple[sp.Expr, int], ...]
115
+ infinity_order: int
116
+ reason: str
117
+ case2_certificates: tuple[KovacicCase2Certificate, ...] = ()
118
+ case3_certificates: tuple[KovacicCase3Certificate, ...] = ()
119
+
120
+ @property
121
+ def is_liouvillian(self) -> bool | None:
122
+ """Return the exact three-valued Liouvillian decision."""
123
+
124
+ if self.outcome in {
125
+ KovacicOutcome.CASE_1,
126
+ KovacicOutcome.CASE_2,
127
+ KovacicOutcome.CASE_3,
128
+ }:
129
+ return True
130
+ if self.outcome is KovacicOutcome.NO_LIOUVILLIAN_SOLUTION:
131
+ return False
132
+ return None
133
+
134
+ def verify(self) -> bool:
135
+ """Replay the stored Kovacic certificate."""
136
+
137
+ r = _normal_form_potential(self.operator)
138
+ if sp.simplify(self.normal_form_potential - r) != 0:
139
+ return False
140
+ if self.outcome is KovacicOutcome.CASE_1:
141
+ return bool(self.factorizations) and all(
142
+ f.verify() for f in self.factorizations
143
+ )
144
+ if self.outcome is KovacicOutcome.CASE_2:
145
+ return bool(self.case2_certificates) and all(
146
+ certificate.verify(r, self.operator.variable)
147
+ for certificate in self.case2_certificates
148
+ )
149
+ if self.outcome is KovacicOutcome.CASE_3:
150
+ return bool(self.case3_certificates) and all(
151
+ certificate.verify(r, self.operator.variable)
152
+ for certificate in self.case3_certificates
153
+ )
154
+ return True
155
+
156
+
157
+ def _normal_form_potential(op: LinearDifferentialOperator) -> sp.Expr:
158
+ normalized = op.normalized()
159
+ x = op.variable
160
+ p = normalized.coefficients[1]
161
+ q = normalized.coefficients[0]
162
+ return sp.cancel(p**2 / 4 + sp.diff(p, x) / 2 - q)
163
+
164
+
165
+ def _pole_data(
166
+ r: sp.Expr, x: sp.Symbol
167
+ ) -> tuple[tuple[tuple[sp.Expr, int], ...], int, sp.Expr]:
168
+ numerator, denominator = sp.cancel(r).as_numer_denom()
169
+ try:
170
+ den_poly = sp.Poly(denominator, x, extension=True)
171
+ num_poly = sp.Poly(numerator, x, extension=True)
172
+ except sp.PolynomialError:
173
+ return (), 0, sp.S.Zero
174
+ roots = sp.roots(den_poly.as_expr(), x)
175
+ finite = tuple(
176
+ sorted(
177
+ ((root, int(mult)) for root, mult in roots.items()),
178
+ key=lambda item: sp.default_sort_key(item[0]),
179
+ )
180
+ )
181
+ infinity_order = int(den_poly.degree() - num_poly.degree())
182
+ infinity_b = (
183
+ sp.cancel(num_poly.LC() / den_poly.LC()) if infinity_order == 2 else sp.S.Zero
184
+ )
185
+ return finite, infinity_order, infinity_b
186
+
187
+
188
+ def _integer_value(value: sp.Expr) -> int | None:
189
+ simplified = sp.simplify(value)
190
+ if simplified.is_integer is True and simplified.is_nonnegative is True:
191
+ return int(simplified)
192
+ return None
193
+
194
+
195
+ def _integer_set(values: list[sp.Expr]) -> tuple[sp.Expr, ...]:
196
+ result = []
197
+ for value in values:
198
+ simplified = sp.simplify(value)
199
+ if simplified.is_integer is True and simplified not in result:
200
+ result.append(simplified)
201
+ return tuple(result)
202
+
203
+
204
+ def _pole_b(r: sp.Expr, x: sp.Symbol, pole: sp.Expr) -> sp.Expr:
205
+ return sp.simplify(sp.limit((x - pole) ** 2 * r, x, pole))
206
+
207
+
208
+ def _monic_auxiliary_solution(
209
+ expr_builder, degree: int, x: sp.Symbol
210
+ ) -> sp.Expr | None:
211
+ if degree == 0:
212
+ return sp.S.One if sp.simplify(expr_builder(sp.S.One)) == 0 else None
213
+ coeffs = sp.symbols(f"_k0:{degree}")
214
+ polynomial = x**degree + sum(coeffs[i] * x**i for i in range(degree))
215
+ expression = sp.cancel(expr_builder(polynomial))
216
+ numerator = sp.together(expression).as_numer_denom()[0]
217
+ try:
218
+ equations = sp.Poly(sp.expand(numerator), x).all_coeffs()
219
+ except sp.PolynomialError:
220
+ return None
221
+ solution_set = sp.linsolve(equations, coeffs)
222
+ if solution_set is sp.EmptySet:
223
+ return None
224
+ for solution in solution_set:
225
+ if any(value.free_symbols & set(coeffs) for value in solution):
226
+ continue
227
+ candidate = sp.expand(polynomial.subs(dict(zip(coeffs, solution, strict=True))))
228
+ if sp.simplify(expr_builder(candidate)) == 0:
229
+ return candidate
230
+ return None
231
+
232
+
233
+ def _case2(
234
+ r: sp.Expr,
235
+ x: sp.Symbol,
236
+ finite: tuple[tuple[sp.Expr, int], ...],
237
+ infinity_order: int,
238
+ infinity_b: sp.Expr,
239
+ ) -> tuple[KovacicCase2Certificate, ...]:
240
+ choices: list[tuple[sp.Expr, ...]] = []
241
+ poles: list[sp.Expr] = []
242
+ for pole, order in finite:
243
+ poles.append(pole)
244
+ if order == 1:
245
+ choices.append((sp.Integer(4),))
246
+ elif order == 2:
247
+ root = sp.sqrt(1 + 4 * _pole_b(r, x, pole))
248
+ choices.append(_integer_set([2, 2 + 2 * root, 2 - 2 * root]))
249
+ else:
250
+ choices.append((sp.Integer(order),))
251
+ if any(not values for values in choices):
252
+ return ()
253
+ if infinity_order > 2:
254
+ infinity_choices = (sp.Integer(0), sp.Integer(2), sp.Integer(4))
255
+ elif infinity_order == 2:
256
+ root = sp.sqrt(1 + 4 * infinity_b)
257
+ infinity_choices = _integer_set([2, 2 + 2 * root, 2 - 2 * root])
258
+ else:
259
+ infinity_choices = (sp.Integer(infinity_order),)
260
+ certificates = []
261
+ for finite_values in product(*choices):
262
+ for e_inf in infinity_choices:
263
+ degree = _integer_value((e_inf - sum(finite_values)) / 2)
264
+ if degree is None:
265
+ continue
266
+ theta = sp.cancel(
267
+ sp.Rational(1, 2)
268
+ * sum(
269
+ e / (x - pole) for e, pole in zip(finite_values, poles, strict=True)
270
+ )
271
+ )
272
+
273
+ def auxiliary(p, theta=theta):
274
+ return (
275
+ sp.diff(p, x, 3)
276
+ + 3 * theta * sp.diff(p, x, 2)
277
+ + (3 * theta**2 + 3 * sp.diff(theta, x) - 4 * r) * sp.diff(p, x)
278
+ + (
279
+ sp.diff(theta, x, 2)
280
+ + 3 * theta * sp.diff(theta, x)
281
+ + theta**3
282
+ - 4 * r * theta
283
+ - 2 * sp.diff(r, x)
284
+ )
285
+ * p
286
+ )
287
+
288
+ p = _monic_auxiliary_solution(auxiliary, degree, x)
289
+ if p is None:
290
+ continue
291
+ phi = sp.cancel(theta + sp.diff(p, x) / p)
292
+ constant = sp.cancel(sp.diff(phi, x) / 2 + phi**2 / 2 - r)
293
+ discriminant = sp.cancel(phi**2 - 4 * constant)
294
+ roots = tuple(
295
+ sp.simplify((phi + sign * sp.sqrt(discriminant)) / 2)
296
+ for sign in (1, -1)
297
+ )
298
+ valid = tuple(
299
+ w for w in roots if sp.simplify(sp.diff(w, x) + w**2 - r) == 0
300
+ )
301
+ if valid:
302
+ certificates.append(KovacicCase2Certificate(degree, theta, p, valid))
303
+ return tuple(certificates)
304
+
305
+
306
+ def _case3_sets(
307
+ n: int,
308
+ r: sp.Expr,
309
+ x: sp.Symbol,
310
+ finite: tuple[tuple[sp.Expr, int], ...],
311
+ infinity_order: int,
312
+ infinity_b: sp.Expr,
313
+ ):
314
+ if any(order not in {1, 2} for _, order in finite) or infinity_order < 2:
315
+ return None
316
+ choices = []
317
+ poles = []
318
+ for pole, order in finite:
319
+ poles.append(pole)
320
+ if order == 1:
321
+ choices.append((sp.Integer(12),))
322
+ else:
323
+ root = sp.sqrt(1 + 4 * _pole_b(r, x, pole))
324
+ values = [
325
+ 6 + sp.Rational(12 * k, n) * root for k in range(-n // 2, n // 2 + 1)
326
+ ]
327
+ choices.append(_integer_set(values))
328
+ root_inf = sp.sqrt(1 + 4 * infinity_b)
329
+ infinity_choices = _integer_set(
330
+ [6 + sp.Rational(12 * k, n) * root_inf for k in range(-n // 2, n // 2 + 1)]
331
+ )
332
+ if any(not values for values in choices) or not infinity_choices:
333
+ return None
334
+ return poles, choices, infinity_choices
335
+
336
+
337
+ def _case3(
338
+ r: sp.Expr,
339
+ x: sp.Symbol,
340
+ finite: tuple[tuple[sp.Expr, int], ...],
341
+ infinity_order: int,
342
+ infinity_b: sp.Expr,
343
+ ) -> tuple[KovacicCase3Certificate, ...]:
344
+ certificates = []
345
+ for n in (4, 6, 12):
346
+ sets = _case3_sets(n, r, x, finite, infinity_order, infinity_b)
347
+ if sets is None:
348
+ continue
349
+ poles, choices, infinity_choices = sets
350
+ s = sp.prod(x - pole for pole in poles)
351
+ for finite_values in product(*choices):
352
+ for e_inf in infinity_choices:
353
+ degree = _integer_value(
354
+ sp.Rational(n, 12) * (e_inf - sum(finite_values))
355
+ )
356
+ if degree is None:
357
+ continue
358
+ theta = sp.cancel(
359
+ sp.Rational(n, 12)
360
+ * sum(
361
+ e / (x - pole)
362
+ for e, pole in zip(finite_values, poles, strict=True)
363
+ )
364
+ )
365
+
366
+ def terminal(polynomial, n=n, s=s, theta=theta):
367
+ by_index = {n + 1: sp.S.Zero, n: -polynomial}
368
+ for i in range(n, -1, -1):
369
+ by_index[i - 1] = sp.cancel(
370
+ -s * sp.diff(by_index[i], x)
371
+ + ((n - i) * sp.diff(s, x) - s * theta) * by_index[i]
372
+ - (n - i) * (i + 1) * s**2 * r * by_index[i + 1]
373
+ )
374
+ return by_index[-1]
375
+
376
+ polynomial = _monic_auxiliary_solution(terminal, degree, x)
377
+ if polynomial is None:
378
+ continue
379
+ by_index = {n + 1: sp.S.Zero, n: -polynomial}
380
+ for i in range(n, -1, -1):
381
+ by_index[i - 1] = sp.cancel(
382
+ -s * sp.diff(by_index[i], x)
383
+ + ((n - i) * sp.diff(s, x) - s * theta) * by_index[i]
384
+ - (n - i) * (i + 1) * s**2 * r * by_index[i + 1]
385
+ )
386
+ if sp.simplify(by_index[-1]) != 0:
387
+ continue
388
+ omega = sp.Symbol("omega")
389
+ equation = sp.expand(
390
+ sum(
391
+ s**i * by_index[i] * omega**i / factorial(n - i)
392
+ for i in range(n + 1)
393
+ )
394
+ )
395
+ certificate = KovacicCase3Certificate(
396
+ n=n,
397
+ degree=degree,
398
+ theta=theta,
399
+ pole_polynomial=sp.expand(s),
400
+ polynomial=polynomial,
401
+ recurrence=tuple(by_index[i] for i in range(n, -2, -1)),
402
+ algebraic_log_derivative=equation,
403
+ )
404
+ if certificate.verify(r, x):
405
+ certificates.append(certificate)
406
+ if certificates:
407
+ return tuple(certificates)
408
+ return ()
409
+
410
+
411
+ def kovacic_analysis(
412
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
413
+ function: sp.FunctionClass | sp.Expr | None = None,
414
+ variable: sp.Symbol | None = None,
415
+ ) -> KovacicAnalysis:
416
+ """Run Kovacic's Cases 1--3 for a rational homogeneous second-order ODE."""
417
+
418
+ op = _coerce_linear_operator(ode, function, variable)
419
+ if not op.is_homogeneous or op.order != 2:
420
+ raise ValueError(
421
+ "Kovacic analysis requires a homogeneous second-order operator"
422
+ )
423
+ normalized = op.normalized()
424
+ x = normalized.variable
425
+ if not all(
426
+ sp.cancel(c).is_rational_function(x) for c in normalized.coefficients[:-1]
427
+ ):
428
+ raise ValueError("Kovacic analysis requires rational-function coefficients")
429
+ potential = _normal_form_potential(op)
430
+ finite, infinity_order, infinity_b = _pole_data(potential, x)
431
+ factors = factor_differential_operator(op)
432
+ if factors:
433
+ return KovacicAnalysis(
434
+ op,
435
+ potential,
436
+ KovacicOutcome.CASE_1,
437
+ 1,
438
+ factors,
439
+ finite,
440
+ infinity_order,
441
+ "a rational Riccati solution certifies Kovacic Case 1",
442
+ )
443
+ case2 = _case2(potential, x, finite, infinity_order, infinity_b)
444
+ if case2:
445
+ return KovacicAnalysis(
446
+ op,
447
+ potential,
448
+ KovacicOutcome.CASE_2,
449
+ 2,
450
+ (),
451
+ finite,
452
+ infinity_order,
453
+ "a quadratic algebraic Riccati certificate proves Kovacic Case 2",
454
+ case2_certificates=case2,
455
+ )
456
+ case3 = _case3(potential, x, finite, infinity_order, infinity_b)
457
+ if case3:
458
+ return KovacicAnalysis(
459
+ op,
460
+ potential,
461
+ KovacicOutcome.CASE_3,
462
+ 3,
463
+ (),
464
+ finite,
465
+ infinity_order,
466
+ "the finite-group polynomial recurrence certifies Kovacic Case 3",
467
+ case3_certificates=case3,
468
+ )
469
+ return KovacicAnalysis(
470
+ op,
471
+ potential,
472
+ KovacicOutcome.NO_LIOUVILLIAN_SOLUTION,
473
+ None,
474
+ (),
475
+ finite,
476
+ infinity_order,
477
+ "Kovacic Cases 1, 2, and 3 all failed",
478
+ )