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/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
|
+
)
|
odeanalysis/wronskian.py
ADDED
|
@@ -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
|
+
)
|