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/turning.py ADDED
@@ -0,0 +1,516 @@
1
+ """Turning points, Liouville normal form, and uniform WKB reductions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+ import sympy as sp
9
+
10
+ from ._symbolic_errors import SYMBOLIC_FAILURES
11
+ from .operator import LinearDifferentialOperator, _coerce_linear_operator
12
+
13
+
14
+ class TurningPointKind(Enum):
15
+ """Multiplicity class of a zero of the Liouville normal-form potential."""
16
+
17
+ SIMPLE = "simple"
18
+ DOUBLE = "double"
19
+ HIGHER = "higher"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LiouvilleNormalForm:
24
+ """Exact reduction of a second-order scalar equation to ``u'' = Q u``.
25
+
26
+ For a monic equation ``y'' + p y' + q y = 0``, the substitution
27
+ ``y = g u`` with ``g'/g = -p/2`` gives
28
+ ``u'' = Q u`` where ``Q = p'/2 + p**2/4 - q``.
29
+ """
30
+
31
+ operator: LinearDifferentialOperator
32
+ p: sp.Expr
33
+ q: sp.Expr
34
+ potential: sp.Expr
35
+ gauge: sp.Expr
36
+ gauge_log_derivative: sp.Expr
37
+
38
+ def verify(self) -> bool:
39
+ """Recompute the normal-form potential and gauge identity exactly."""
40
+
41
+ x = self.operator.variable
42
+ op = self.operator.normalized()
43
+ if op.order != 2 or not op.is_homogeneous:
44
+ return False
45
+ p = sp.cancel(op.coefficients[1])
46
+ q = sp.cancel(op.coefficients[0])
47
+ expected = sp.cancel(sp.diff(p, x) / 2 + p**2 / 4 - q)
48
+ return (
49
+ sp.simplify(self.p - p) == 0
50
+ and sp.simplify(self.q - q) == 0
51
+ and sp.simplify(self.potential - expected) == 0
52
+ and sp.simplify(self.gauge_log_derivative + p / 2) == 0
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class TurningPoint:
58
+ """A finite zero of the Liouville normal-form potential."""
59
+
60
+ point: sp.Expr
61
+ multiplicity: int
62
+ kind: TurningPointKind
63
+ leading_coefficient: sp.Expr
64
+ normal_form: LiouvilleNormalForm
65
+
66
+ def verify(self) -> bool:
67
+ """Verify the zero multiplicity and leading local coefficient."""
68
+
69
+ x = self.normal_form.operator.variable
70
+ q = self.normal_form.potential
71
+ h = sp.Symbol("_h")
72
+ local = sp.cancel(q.subs(x, self.point + h))
73
+ for order in range(self.multiplicity):
74
+ if sp.simplify(sp.limit(local / h**order, h, 0)) not in (0, sp.S.Zero):
75
+ return False
76
+ coeff = sp.simplify(sp.limit(local / h**self.multiplicity, h, 0))
77
+ return coeff != 0 and sp.simplify(coeff - self.leading_coefficient) == 0
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class TurningPointAnalysis:
82
+ """Resolved finite turning points of a second-order normal-form equation."""
83
+
84
+ normal_form: LiouvilleNormalForm
85
+ points: tuple[TurningPoint, ...]
86
+ complete: bool
87
+ limitation: str | None = None
88
+
89
+ def verify(self) -> bool:
90
+ """Verify every reported point and, when complete, the full numerator degree."""
91
+
92
+ if not self.normal_form.verify() or not all(
93
+ point.verify() for point in self.points
94
+ ):
95
+ return False
96
+ if not self.complete:
97
+ return True
98
+ x = self.normal_form.operator.variable
99
+ numerator, denominator = sp.fraction(sp.cancel(self.normal_form.potential))
100
+ try:
101
+ degree = sp.Poly(numerator, x).degree()
102
+ except sp.PolynomialError:
103
+ return False
104
+ multiplicity = sum(point.multiplicity for point in self.points)
105
+ if degree != multiplicity:
106
+ return False
107
+ return all(
108
+ sp.simplify(denominator.subs(x, point.point)) != 0 for point in self.points
109
+ )
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class WKBExpansion:
114
+ """Formal Riccati/WKB expansion for ``epsilon**2 u'' = Q u``.
115
+
116
+ ``coefficients[n]`` is ``S_n`` in
117
+ ``S = sum(epsilon**n*S_n)`` with ``epsilon*S' + S**2 = Q``.
118
+ The associated normal-form solution is
119
+ ``u = exp(Integral(S, x)/epsilon)``.
120
+ """
121
+
122
+ normal_form: LiouvilleNormalForm
123
+ parameter: sp.Symbol
124
+ branch: int
125
+ coefficients: tuple[sp.Expr, ...]
126
+ log_derivative_series: sp.Expr
127
+ normal_form_solution: sp.Expr
128
+ original_solution: sp.Expr
129
+
130
+ @property
131
+ def order(self) -> int:
132
+ """Highest computed Riccati coefficient index."""
133
+
134
+ return len(self.coefficients) - 1
135
+
136
+ def verify(self) -> bool:
137
+ """Replay the Riccati recurrence through the requested order."""
138
+
139
+ x = self.normal_form.operator.variable
140
+ eps = self.parameter
141
+ if self.branch not in (-1, 1) or not self.coefficients:
142
+ return False
143
+ s0 = self.coefficients[0]
144
+ if sp.simplify(s0**2 - self.normal_form.potential) != 0:
145
+ return False
146
+ for n in range(1, len(self.coefficients)):
147
+ convolution = sum(
148
+ self.coefficients[j] * self.coefficients[n - j] for j in range(1, n)
149
+ )
150
+ expected = sp.cancel(
151
+ -(sp.diff(self.coefficients[n - 1], x) + convolution) / (2 * s0)
152
+ )
153
+ if sp.simplify(self.coefficients[n] - expected) != 0:
154
+ return False
155
+ expected_series = sp.Add(
156
+ *(eps**n * value for n, value in enumerate(self.coefficients))
157
+ )
158
+ return sp.simplify(self.log_derivative_series - expected_series) == 0
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class UniformWKBReduction:
163
+ """Uniform Liouville-Green reduction near a simple or double turning point.
164
+
165
+ The exact transformed equation has the form
166
+ ``epsilon**2 W'' = (canonical_potential + epsilon**2*residual) W``.
167
+ ``residual == 0`` therefore means the reduction is an exact canonical
168
+ equation rather than only a uniform leading model.
169
+ """
170
+
171
+ turning_point: TurningPoint
172
+ parameter: sp.Symbol
173
+ canonical_variable: sp.Symbol
174
+ variable_transform: sp.Expr
175
+ phase_integral: sp.Expr
176
+ amplitude: sp.Expr
177
+ canonical_family: str
178
+ canonical_potential: sp.Expr
179
+ residual: sp.Expr
180
+
181
+ @property
182
+ def original_amplitude(self) -> sp.Expr:
183
+ """Combined Liouville and uniformizing amplitude in the original equation."""
184
+
185
+ return sp.simplify(self.turning_point.normal_form.gauge * self.amplitude)
186
+
187
+ @property
188
+ def is_exact(self) -> bool:
189
+ """Whether the transformed equation has no Liouville-Green residual."""
190
+
191
+ return self.residual == 0
192
+
193
+ def verify(self) -> bool:
194
+ """Verify the defining potential map and transformed residual exactly."""
195
+
196
+ x = self.turning_point.normal_form.operator.variable
197
+ zeta = self.variable_transform
198
+ zp = sp.diff(zeta, x)
199
+ if sp.simplify(zp) == 0:
200
+ return False
201
+ q = self.turning_point.normal_form.potential
202
+ phase_prime = sp.diff(self.phase_integral, x)
203
+ if sp.simplify(phase_prime**2 - q) != 0:
204
+ return False
205
+ if self.canonical_family == "airy":
206
+ expected_transform = sp.Pow(
207
+ sp.Rational(3, 2) * self.phase_integral, sp.Rational(2, 3)
208
+ )
209
+ elif self.canonical_family == "weber":
210
+ expected_transform = sp.sqrt(2 * self.phase_integral)
211
+ else:
212
+ return False
213
+ if zeta != expected_transform and sp.simplify(zeta - expected_transform) != 0:
214
+ return False
215
+ zpp = sp.diff(zp, x)
216
+ zppp = sp.diff(zpp, x)
217
+ expected_residual = zppp / (2 * zp**3) - 3 * zpp**2 / (4 * zp**4)
218
+ return (
219
+ self.residual == expected_residual
220
+ or sp.simplify(self.residual - expected_residual) == 0
221
+ )
222
+
223
+
224
+ def liouville_normal_form(
225
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
226
+ function: sp.FunctionClass | sp.Expr | None = None,
227
+ variable: sp.Symbol | None = None,
228
+ ) -> LiouvilleNormalForm:
229
+ """Reduce a homogeneous second-order scalar equation to Liouville normal form."""
230
+
231
+ op = _coerce_linear_operator(ode, function, variable)
232
+ if op.order != 2:
233
+ raise ValueError("Liouville normal form requires a second-order equation")
234
+ if not op.is_homogeneous:
235
+ raise ValueError("Liouville normal form requires a homogeneous equation")
236
+ normalized = op.normalized()
237
+ x = normalized.variable
238
+ p = sp.cancel(normalized.coefficients[1])
239
+ q = sp.cancel(normalized.coefficients[0])
240
+ h = sp.cancel(-p / 2)
241
+ potential = sp.cancel(sp.diff(p, x) / 2 + p**2 / 4 - q)
242
+ gauge = sp.exp(sp.Integral(h, x))
243
+ return LiouvilleNormalForm(normalized, p, q, potential, gauge, h)
244
+
245
+
246
+ def _resolved_polynomial_roots(
247
+ polynomial: sp.Expr,
248
+ variable: sp.Symbol,
249
+ ) -> tuple[tuple[sp.Expr, int], bool]:
250
+ """Return exact roots with multiplicity and whether all roots were resolved."""
251
+
252
+ try:
253
+ poly = sp.Poly(polynomial, variable)
254
+ except sp.PolynomialError:
255
+ return (), False
256
+ if poly.is_zero:
257
+ return (), False
258
+ degree = int(poly.degree())
259
+ try:
260
+ roots = sp.roots(poly.as_expr(), variable, cubics=False, quartics=False)
261
+ except SYMBOLIC_FAILURES:
262
+ roots = {}
263
+ result = [(sp.simplify(root), int(mult)) for root, mult in roots.items()]
264
+ if sum(mult for _, mult in result) != degree:
265
+ try:
266
+ all_roots = poly.all_roots(radicals=False)
267
+ except SYMBOLIC_FAILURES:
268
+ all_roots = []
269
+ if len(all_roots) == degree:
270
+ counts: dict[sp.Expr, int] = {}
271
+ for root in all_roots:
272
+ root = sp.simplify(root)
273
+ counts[root] = counts.get(root, 0) + 1
274
+ result = list(counts.items())
275
+ result.sort(key=lambda item: sp.default_sort_key(item[0]))
276
+ return tuple(result), sum(mult for _, mult in result) == degree
277
+
278
+
279
+ def _turning_kind(multiplicity: int) -> TurningPointKind:
280
+ if multiplicity == 1:
281
+ return TurningPointKind.SIMPLE
282
+ if multiplicity == 2:
283
+ return TurningPointKind.DOUBLE
284
+ return TurningPointKind.HIGHER
285
+
286
+
287
+ def analyze_turning_points(
288
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
289
+ function: sp.FunctionClass | sp.Expr | None = None,
290
+ variable: sp.Symbol | None = None,
291
+ ) -> TurningPointAnalysis:
292
+ """Find finite turning points as zeros of the Liouville normal-form potential."""
293
+
294
+ normal = liouville_normal_form(ode, function, variable)
295
+ x = normal.operator.variable
296
+ numerator, denominator = sp.fraction(sp.cancel(normal.potential))
297
+ roots, complete = _resolved_polynomial_roots(numerator, x)
298
+ points: list[TurningPoint] = []
299
+ for root, multiplicity in roots:
300
+ den_value = sp.simplify(denominator.subs(x, root))
301
+ if den_value == 0:
302
+ continue
303
+ h = sp.Symbol("_h")
304
+ local = sp.cancel(normal.potential.subs(x, root + h))
305
+ leading = sp.simplify(sp.limit(local / h**multiplicity, h, 0))
306
+ points.append(
307
+ TurningPoint(
308
+ point=root,
309
+ multiplicity=multiplicity,
310
+ kind=_turning_kind(multiplicity),
311
+ leading_coefficient=leading,
312
+ normal_form=normal,
313
+ )
314
+ )
315
+ limitation = (
316
+ None
317
+ if complete
318
+ else "could not resolve every zero of the normal-form potential"
319
+ )
320
+ return TurningPointAnalysis(normal, tuple(points), complete, limitation)
321
+
322
+
323
+ def turning_points(
324
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
325
+ function: sp.FunctionClass | sp.Expr | None = None,
326
+ variable: sp.Symbol | None = None,
327
+ ) -> tuple[TurningPoint, ...]:
328
+ """Return the resolved finite turning points of a second-order equation."""
329
+
330
+ return analyze_turning_points(ode, function, variable).points
331
+
332
+
333
+ def classify_turning_point(
334
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
335
+ function: sp.FunctionClass | sp.Expr | None = None,
336
+ variable: sp.Symbol | None = None,
337
+ *,
338
+ point: sp.Expr,
339
+ ) -> TurningPoint:
340
+ """Classify a specified finite point by the zero multiplicity of ``Q``."""
341
+
342
+ normal = liouville_normal_form(ode, function, variable)
343
+ x = normal.operator.variable
344
+ h = sp.Symbol("_h")
345
+ local = sp.cancel(normal.potential.subs(x, sp.sympify(point) + h))
346
+ if sp.simplify(sp.limit(local, h, 0)) != 0:
347
+ raise ValueError(f"point {point!s} is not a turning point")
348
+ numerator, denominator = sp.fraction(local)
349
+ try:
350
+ poly = sp.Poly(numerator, h)
351
+ powers = [monomial[0] for monomial, coeff in poly.terms() if coeff != 0]
352
+ multiplicity = min(powers) if powers else 0
353
+ except sp.PolynomialError:
354
+ multiplicity = 0
355
+ if multiplicity < 1 or sp.simplify(denominator.subs(h, 0)) == 0:
356
+ raise ValueError(f"could not certify turning-point multiplicity at {point!s}")
357
+ leading = sp.simplify(sp.limit(local / h**multiplicity, h, 0))
358
+ return TurningPoint(
359
+ sp.sympify(point), multiplicity, _turning_kind(multiplicity), leading, normal
360
+ )
361
+
362
+
363
+ def wkb_expansion(
364
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
365
+ function: sp.FunctionClass | sp.Expr | None = None,
366
+ variable: sp.Symbol | None = None,
367
+ *,
368
+ order: int = 2,
369
+ parameter: sp.Symbol | None = None,
370
+ ) -> tuple[WKBExpansion, WKBExpansion]:
371
+ """Return both formal WKB branches through Riccati coefficient ``order``.
372
+
373
+ The normal-form equation is interpreted as ``epsilon**2 u'' = Q u``.
374
+ Setting ``epsilon=1`` recovers the ordinary Liouville-Green ansatz, while
375
+ retaining the symbol makes the asymptotic order bookkeeping explicit.
376
+ """
377
+
378
+ if order < 0:
379
+ raise ValueError("order must be nonnegative")
380
+ normal = liouville_normal_form(ode, function, variable)
381
+ if sp.simplify(normal.potential) == 0:
382
+ raise ValueError("WKB expansion requires a nonzero normal-form potential")
383
+ x = normal.operator.variable
384
+ eps = parameter if parameter is not None else sp.Symbol("epsilon", positive=True)
385
+ if eps == x:
386
+ raise ValueError("WKB parameter must differ from the ODE variable")
387
+ result: list[WKBExpansion] = []
388
+ for branch in (1, -1):
389
+ coefficients = [sp.simplify(branch * sp.sqrt(normal.potential))]
390
+ for n in range(1, order + 1):
391
+ convolution = sum(
392
+ coefficients[j] * coefficients[n - j] for j in range(1, n)
393
+ )
394
+ value = sp.cancel(
395
+ -(sp.diff(coefficients[n - 1], x) + convolution) / (2 * coefficients[0])
396
+ )
397
+ coefficients.append(value)
398
+ series = sp.Add(*(eps**n * value for n, value in enumerate(coefficients)))
399
+ exponent = sp.Integral(series, x) / eps
400
+ u = sp.exp(exponent)
401
+ result.append(
402
+ WKBExpansion(
403
+ normal,
404
+ eps,
405
+ branch,
406
+ tuple(coefficients),
407
+ series,
408
+ u,
409
+ normal.gauge * u,
410
+ )
411
+ )
412
+ return result[0], result[1]
413
+
414
+
415
+ def _uniform_coordinate(
416
+ turning_point: TurningPoint,
417
+ family: str,
418
+ ) -> tuple[sp.Expr, sp.Expr]:
419
+ x = turning_point.normal_form.operator.variable
420
+ q = turning_point.normal_form.potential
421
+ x0 = turning_point.point
422
+ local_power = (x - x0) ** turning_point.multiplicity
423
+ quotient = sp.cancel(q / local_power)
424
+ if x not in quotient.free_symbols:
425
+ exponent = sp.Rational(turning_point.multiplicity, 2) + 1
426
+ phase = sp.sqrt(quotient) * (x - x0) ** exponent / exponent
427
+ else:
428
+ t = sp.Dummy("t")
429
+ integrand = sp.sqrt(q).subs(x, t)
430
+ phase = sp.Integral(integrand, (t, x0, x))
431
+ if family == "airy":
432
+ zeta = sp.Pow(sp.Rational(3, 2) * phase, sp.Rational(2, 3))
433
+ return zeta, phase
434
+ if family == "weber":
435
+ return sp.sqrt(2 * phase), phase
436
+ raise ValueError(f"unknown uniform family {family!r}")
437
+
438
+
439
+ def _uniform_reduction(
440
+ turning_point: TurningPoint,
441
+ *,
442
+ family: str,
443
+ parameter: sp.Symbol | None,
444
+ canonical_variable: sp.Symbol | None,
445
+ ) -> UniformWKBReduction:
446
+ x = turning_point.normal_form.operator.variable
447
+ eps = parameter if parameter is not None else sp.Symbol("epsilon", positive=True)
448
+ z = canonical_variable if canonical_variable is not None else sp.Symbol("zeta")
449
+ zeta, phase = _uniform_coordinate(turning_point, family)
450
+ zp = sp.diff(zeta, x)
451
+ amplitude = zp ** sp.Rational(-1, 2)
452
+ canonical = z if family == "airy" else z**2
453
+ zpp = sp.diff(zp, x)
454
+ zppp = sp.diff(zpp, x)
455
+ residual = zppp / (2 * zp**3) - 3 * zpp**2 / (4 * zp**4)
456
+ return UniformWKBReduction(
457
+ turning_point,
458
+ eps,
459
+ z,
460
+ zeta,
461
+ phase,
462
+ amplitude,
463
+ family,
464
+ canonical,
465
+ residual,
466
+ )
467
+
468
+
469
+ def airy_uniformization(
470
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
471
+ function: sp.FunctionClass | sp.Expr | None = None,
472
+ variable: sp.Symbol | None = None,
473
+ *,
474
+ point: sp.Expr,
475
+ parameter: sp.Symbol | None = None,
476
+ canonical_variable: sp.Symbol | None = None,
477
+ ) -> UniformWKBReduction:
478
+ """Construct the Liouville-Green Airy reduction at a simple turning point."""
479
+
480
+ turning = classify_turning_point(ode, function, variable, point=point)
481
+ if turning.kind is not TurningPointKind.SIMPLE:
482
+ raise ValueError("Airy uniformization requires a simple turning point")
483
+ return _uniform_reduction(
484
+ turning,
485
+ family="airy",
486
+ parameter=parameter,
487
+ canonical_variable=canonical_variable,
488
+ )
489
+
490
+
491
+ def weber_uniformization(
492
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
493
+ function: sp.FunctionClass | sp.Expr | None = None,
494
+ variable: sp.Symbol | None = None,
495
+ *,
496
+ point: sp.Expr,
497
+ parameter: sp.Symbol | None = None,
498
+ canonical_variable: sp.Symbol | None = None,
499
+ ) -> UniformWKBReduction:
500
+ """Construct the degenerate Weber reduction at a double turning point.
501
+
502
+ This function treats an isolated double zero, whose canonical leading
503
+ potential is ``zeta**2``. Uniformization of two distinct coalescing turning
504
+ points with a nonzero Weber parameter requires parameter-dependent
505
+ confluence analysis and is outside this routine's contract.
506
+ """
507
+
508
+ turning = classify_turning_point(ode, function, variable, point=point)
509
+ if turning.kind is not TurningPointKind.DOUBLE:
510
+ raise ValueError("Weber uniformization requires a double turning point")
511
+ return _uniform_reduction(
512
+ turning,
513
+ family="weber",
514
+ parameter=parameter,
515
+ canonical_variable=canonical_variable,
516
+ )
@@ -0,0 +1,110 @@
1
+ """Wronskian evolution and basis-independence certificates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import sympy as sp
8
+ from funcprops import normalize_assumptions
9
+
10
+ from ._assumptions import zero_status
11
+ from ._local import linear_ode_data
12
+ from .operator import LinearDifferentialOperator
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class WronskianAnalysis:
17
+ """Abel identity and optional explicit-basis independence evidence."""
18
+
19
+ operator: LinearDifferentialOperator
20
+ differential_equation: sp.Equality
21
+ abel_factor: sp.Expr
22
+ wronskian: sp.Expr | None
23
+ independent: bool | None
24
+ assumptions: sp.Expr
25
+ witness_point: sp.Expr | None = None
26
+ abel_identity_verified: bool | None = None
27
+
28
+
29
+ def abel_wronskian(
30
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
31
+ function: sp.FunctionClass | sp.Expr | None = None,
32
+ variable: sp.Symbol | None = None,
33
+ *,
34
+ constant: sp.Expr | None = None,
35
+ ) -> sp.Expr:
36
+ """Return Abel's general Wronskian factor for a homogeneous scalar ODE."""
37
+ data = linear_ode_data(ode, function, variable)
38
+ operator = data.operator
39
+ if not operator.is_homogeneous:
40
+ raise ValueError("Abel's identity requires a homogeneous equation")
41
+ x = operator.variable
42
+ a_nm1 = data.normalized_coefficients[operator.order - 1]
43
+ c = sp.Symbol("C_W", nonzero=True) if constant is None else sp.sympify(constant)
44
+ return c * sp.exp(-sp.Integral(a_nm1, x))
45
+
46
+
47
+ def _nonzero_witness(
48
+ expr: sp.Expr, variable: sp.Symbol, assumptions: sp.Expr
49
+ ) -> sp.Expr | None:
50
+ """Find a cheap exact point witnessing a nonzero expression."""
51
+ for point in (sp.S.Zero, sp.S.One, -sp.S.One, sp.Integer(2)):
52
+ try:
53
+ value = sp.cancel(sp.together(expr.subs(variable, point)))
54
+ except (TypeError, ValueError, ZeroDivisionError):
55
+ continue
56
+ if value.has(sp.zoo, sp.oo, -sp.oo, sp.nan):
57
+ continue
58
+ if zero_status(value, assumptions) is False:
59
+ return point
60
+ return None
61
+
62
+
63
+ def wronskian_analysis(
64
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
65
+ function: sp.FunctionClass | sp.Expr | None = None,
66
+ variable: sp.Symbol | None = None,
67
+ *,
68
+ basis: tuple[sp.Expr, ...] | list[sp.Expr] | None = None,
69
+ assumptions: sp.Expr | bool = True,
70
+ ) -> WronskianAnalysis:
71
+ """Return Abel evolution and certify independence for an explicit basis when possible."""
72
+ data = linear_ode_data(ode, function, variable)
73
+ operator = data.operator
74
+ if not operator.is_homogeneous:
75
+ raise ValueError("Wronskian analysis requires a homogeneous equation")
76
+ assumptions = normalize_assumptions(assumptions)
77
+ x = operator.variable
78
+ a_nm1 = data.normalized_coefficients[operator.order - 1]
79
+ w = sp.Function("W")(x)
80
+ equation = sp.Eq(sp.diff(w, x), -a_nm1 * w)
81
+ factor = abel_wronskian(operator)
82
+ explicit = None
83
+ independent = None
84
+ witness = None
85
+ abel_verified = None
86
+ if basis is not None:
87
+ if len(basis) != operator.order:
88
+ raise ValueError("basis length must equal the ODE order")
89
+ explicit = sp.factor(sp.wronskian(tuple(map(sp.sympify, basis)), x))
90
+ z = zero_status(explicit, assumptions)
91
+ independent = None if z is None else not z
92
+ if independent is None:
93
+ residual = sp.cancel(sp.together(sp.diff(explicit, x) + a_nm1 * explicit))
94
+ abel_verified = zero_status(residual, assumptions)
95
+ if abel_verified is True:
96
+ witness = _nonzero_witness(explicit, x, assumptions)
97
+ if witness is not None:
98
+ independent = True
99
+ elif independent is True:
100
+ witness = _nonzero_witness(explicit, x, assumptions)
101
+ return WronskianAnalysis(
102
+ operator,
103
+ equation,
104
+ factor,
105
+ explicit,
106
+ independent,
107
+ assumptions,
108
+ witness,
109
+ abel_verified,
110
+ )