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/frobenius.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Arbitrary-order Frobenius analysis at regular singular points."""
|
|
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 assumption_substitutions, zero_status
|
|
11
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
12
|
+
from .diagnostics import ReductionDiagnostic
|
|
13
|
+
from .operator import LinearDifferentialOperator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class FrobeniusResonance:
|
|
18
|
+
"""A pair of indicial roots separated by a positive integer."""
|
|
19
|
+
|
|
20
|
+
larger_root: sp.Expr
|
|
21
|
+
smaller_root: sp.Expr
|
|
22
|
+
difference: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class FrobeniusBranch:
|
|
27
|
+
"""A formal Frobenius branch through a chosen indicial exponent."""
|
|
28
|
+
|
|
29
|
+
exponent: sp.Expr
|
|
30
|
+
multiplicity: int
|
|
31
|
+
coefficients: tuple[sp.Expr, ...]
|
|
32
|
+
series: sp.Expr
|
|
33
|
+
resonant_orders: tuple[int, ...] = ()
|
|
34
|
+
obstructed_orders: tuple[int, ...] = ()
|
|
35
|
+
free_orders: tuple[int, ...] = ()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def logarithm_may_be_required(self) -> bool:
|
|
39
|
+
return self.multiplicity > 1 or bool(self.obstructed_orders)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def logarithm_required(self) -> bool:
|
|
43
|
+
"""Whether the pure-power recurrence has a certified obstruction."""
|
|
44
|
+
return bool(self.obstructed_orders)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class FrobeniusAnalysis:
|
|
49
|
+
"""Formal data for a regular singular point of an arbitrary-order ODE."""
|
|
50
|
+
|
|
51
|
+
point: sp.Expr
|
|
52
|
+
operator: LinearDifferentialOperator
|
|
53
|
+
indicial_polynomial: sp.Expr
|
|
54
|
+
indicial_variable: sp.Symbol
|
|
55
|
+
root_multiplicities: tuple[tuple[sp.Expr, int], ...]
|
|
56
|
+
resonances: tuple[FrobeniusResonance, ...]
|
|
57
|
+
branches: tuple[FrobeniusBranch, ...]
|
|
58
|
+
terms: int
|
|
59
|
+
diagnostics: tuple[ReductionDiagnostic, ...] = ()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def roots(self) -> tuple[sp.Expr, ...]:
|
|
63
|
+
return tuple(
|
|
64
|
+
root for root, mult in self.root_multiplicities for _ in range(mult)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def has_resonance(self) -> bool:
|
|
69
|
+
return bool(self.resonances) or any(
|
|
70
|
+
mult > 1 for _, mult in self.root_multiplicities
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def logarithm_required(self) -> bool:
|
|
75
|
+
return any(branch.logarithm_required for branch in self.branches)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def logarithm_may_be_required(self) -> bool:
|
|
79
|
+
return any(branch.logarithm_may_be_required for branch in self.branches)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _regularized_coefficients(
|
|
83
|
+
operator: LinearDifferentialOperator,
|
|
84
|
+
point: sp.Expr,
|
|
85
|
+
) -> tuple[sp.Expr, ...]:
|
|
86
|
+
"""Return analytic ``b_j=h^(n-j) p_j`` for a monic regular-singular operator."""
|
|
87
|
+
|
|
88
|
+
normalized = operator.normalized()
|
|
89
|
+
x = operator.variable
|
|
90
|
+
n = operator.order
|
|
91
|
+
return tuple(
|
|
92
|
+
sp.cancel(sp.together((x - point) ** (n - j) * normalized.coefficients[j]))
|
|
93
|
+
for j in range(n + 1)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _taylor_coeff(
|
|
98
|
+
expr: sp.Expr, variable: sp.Symbol, point: sp.Expr, k: int
|
|
99
|
+
) -> sp.Expr:
|
|
100
|
+
if k == 0:
|
|
101
|
+
return sp.simplify(sp.limit(expr, variable, point))
|
|
102
|
+
deriv = sp.diff(expr, variable, k)
|
|
103
|
+
return sp.simplify(sp.limit(deriv, variable, point) / sp.factorial(k))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _indicial_polynomial(
|
|
107
|
+
b: tuple[sp.Expr, ...],
|
|
108
|
+
variable: sp.Symbol,
|
|
109
|
+
point: sp.Expr,
|
|
110
|
+
r: sp.Symbol,
|
|
111
|
+
) -> sp.Expr:
|
|
112
|
+
return sp.factor(
|
|
113
|
+
sp.expand(
|
|
114
|
+
sum(
|
|
115
|
+
_taylor_coeff(bj, variable, point, 0) * sp.ff(r, j)
|
|
116
|
+
for j, bj in enumerate(b)
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _root_data(poly: sp.Expr, r: sp.Symbol) -> tuple[tuple[sp.Expr, int], ...]:
|
|
123
|
+
# Avoid forcing generic cubic/quartic radicals with symbolic parameters.
|
|
124
|
+
# SymPy can still recover rational/factorable roots with radicals disabled.
|
|
125
|
+
try:
|
|
126
|
+
roots = sp.roots(poly, r, cubics=False, quartics=False)
|
|
127
|
+
except SYMBOLIC_FAILURES:
|
|
128
|
+
roots = {}
|
|
129
|
+
if roots:
|
|
130
|
+
return tuple(
|
|
131
|
+
sorted(roots.items(), key=lambda item: sp.default_sort_key(item[0]))
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
p = sp.Poly(poly, r)
|
|
135
|
+
except SYMBOLIC_FAILURES:
|
|
136
|
+
return ()
|
|
137
|
+
if poly.free_symbols - {r}:
|
|
138
|
+
return ()
|
|
139
|
+
try:
|
|
140
|
+
all_roots = p.all_roots()
|
|
141
|
+
except SYMBOLIC_FAILURES:
|
|
142
|
+
return ()
|
|
143
|
+
counts: dict[sp.Expr, int] = {}
|
|
144
|
+
for root in all_roots:
|
|
145
|
+
counts[root] = counts.get(root, 0) + 1
|
|
146
|
+
return tuple(sorted(counts.items(), key=lambda item: sp.default_sort_key(item[0])))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _integer_difference(a: sp.Expr, b: sp.Expr) -> int | None:
|
|
150
|
+
d = sp.simplify(a - b)
|
|
151
|
+
if d.is_Integer and d.is_positive:
|
|
152
|
+
return int(d)
|
|
153
|
+
if d.is_number:
|
|
154
|
+
try:
|
|
155
|
+
val = int(d)
|
|
156
|
+
except TypeError:
|
|
157
|
+
return None
|
|
158
|
+
if sp.simplify(d - val) == 0 and val > 0:
|
|
159
|
+
return val
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resonances(
|
|
164
|
+
roots: tuple[tuple[sp.Expr, int], ...],
|
|
165
|
+
) -> tuple[FrobeniusResonance, ...]:
|
|
166
|
+
result: list[FrobeniusResonance] = []
|
|
167
|
+
distinct = [root for root, _ in roots]
|
|
168
|
+
for a in distinct:
|
|
169
|
+
for b in distinct:
|
|
170
|
+
diff = _integer_difference(a, b)
|
|
171
|
+
if diff is not None:
|
|
172
|
+
result.append(FrobeniusResonance(a, b, diff))
|
|
173
|
+
unique = {(x.larger_root, x.smaller_root, x.difference): x for x in result}
|
|
174
|
+
return tuple(
|
|
175
|
+
sorted(
|
|
176
|
+
unique.values(),
|
|
177
|
+
key=lambda x: (x.difference, sp.default_sort_key(x.smaller_root)),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _branch(
|
|
183
|
+
b: tuple[sp.Expr, ...],
|
|
184
|
+
variable: sp.Symbol,
|
|
185
|
+
point: sp.Expr,
|
|
186
|
+
indicial: sp.Expr,
|
|
187
|
+
r: sp.Symbol,
|
|
188
|
+
root: sp.Expr,
|
|
189
|
+
multiplicity: int,
|
|
190
|
+
terms: int,
|
|
191
|
+
assumptions: sp.Expr | bool = True,
|
|
192
|
+
) -> FrobeniusBranch:
|
|
193
|
+
# b[j][q] is the q-th Taylor coefficient of h^(n-j) p_j.
|
|
194
|
+
bcoeff = [
|
|
195
|
+
tuple(_taylor_coeff(bj, variable, point, q) for q in range(terms)) for bj in b
|
|
196
|
+
]
|
|
197
|
+
coeffs: list[sp.Expr] = [sp.S.One]
|
|
198
|
+
resonant: list[int] = []
|
|
199
|
+
obstructed: list[int] = []
|
|
200
|
+
free: list[int] = []
|
|
201
|
+
|
|
202
|
+
for m in range(1, terms):
|
|
203
|
+
numerator = sp.S.Zero
|
|
204
|
+
for q in range(1, m + 1):
|
|
205
|
+
prev = coeffs[m - q]
|
|
206
|
+
inner = sum(bcoeff[j][q] * sp.ff(root + m - q, j) for j in range(len(b)))
|
|
207
|
+
numerator += prev * inner
|
|
208
|
+
numerator = sp.simplify(numerator)
|
|
209
|
+
denominator = sp.simplify(indicial.subs(r, root + m))
|
|
210
|
+
znum = zero_status(numerator, assumptions)
|
|
211
|
+
if znum is True and denominator.is_zero is None and denominator.free_symbols:
|
|
212
|
+
coeffs.append(sp.S.Zero)
|
|
213
|
+
continue
|
|
214
|
+
zden = zero_status(denominator, assumptions)
|
|
215
|
+
if zden is None and znum is True:
|
|
216
|
+
# Zero is always a valid normalized coefficient here. A special
|
|
217
|
+
# parameter stratum may make the diagonal factor vanish and add a
|
|
218
|
+
# free coefficient, but it does not obstruct this branch.
|
|
219
|
+
coeffs.append(sp.S.Zero)
|
|
220
|
+
continue
|
|
221
|
+
if zden is True:
|
|
222
|
+
resonant.append(m)
|
|
223
|
+
if znum is True:
|
|
224
|
+
# A genuinely free coefficient belongs to the homogeneous solution
|
|
225
|
+
# space. Use a named symbol so downstream code can retain it.
|
|
226
|
+
am = sp.Symbol(f"a{m}")
|
|
227
|
+
coeffs.append(am)
|
|
228
|
+
free.append(m)
|
|
229
|
+
else:
|
|
230
|
+
# A pure power Frobenius branch cannot satisfy this coefficient
|
|
231
|
+
# equation; a logarithmic companion is generally required.
|
|
232
|
+
coeffs.append(sp.S.Zero)
|
|
233
|
+
obstructed.append(m)
|
|
234
|
+
continue
|
|
235
|
+
if zden is None:
|
|
236
|
+
# Keep the formal quotient without declaring resonance.
|
|
237
|
+
coeffs.append(sp.cancel(-numerator / denominator))
|
|
238
|
+
continue
|
|
239
|
+
coeffs.append(sp.cancel(-numerator / denominator))
|
|
240
|
+
|
|
241
|
+
h = variable - point
|
|
242
|
+
series = sp.expand(h**root * sum(coeffs[m] * h**m for m in range(len(coeffs))))
|
|
243
|
+
return FrobeniusBranch(
|
|
244
|
+
exponent=root,
|
|
245
|
+
multiplicity=multiplicity,
|
|
246
|
+
coefficients=tuple(coeffs),
|
|
247
|
+
series=series,
|
|
248
|
+
resonant_orders=tuple(resonant),
|
|
249
|
+
obstructed_orders=tuple(obstructed),
|
|
250
|
+
free_orders=tuple(free),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def frobenius_analysis(
|
|
255
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
256
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
257
|
+
variable: sp.Symbol | None = None,
|
|
258
|
+
point: sp.Expr = 0,
|
|
259
|
+
*,
|
|
260
|
+
terms: int = 6,
|
|
261
|
+
assumptions: sp.Expr | bool = True,
|
|
262
|
+
) -> FrobeniusAnalysis:
|
|
263
|
+
"""Analyze a regular singular point using the arbitrary-order Frobenius recurrence.
|
|
264
|
+
|
|
265
|
+
The recurrence is derived from ``h^n L[y]=0`` with ``h=x-x0``. If
|
|
266
|
+
``b_j(h)=h^(n-j) p_j(h)`` and ``y=h^r sum a_m h^m``, then the coefficient
|
|
267
|
+
of ``h^(r+m)`` gives a recurrence whose diagonal factor is the indicial
|
|
268
|
+
polynomial evaluated at ``r+m``. Vanishing diagonal factors are retained
|
|
269
|
+
explicitly as resonant/free/obstructed orders rather than divided away.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
if terms < 1:
|
|
273
|
+
raise ValueError("terms must be at least one")
|
|
274
|
+
assumptions = normalize_assumptions(assumptions)
|
|
275
|
+
if isinstance(ode, LinearDifferentialOperator):
|
|
276
|
+
operator = ode
|
|
277
|
+
else:
|
|
278
|
+
if function is None or variable is None:
|
|
279
|
+
raise TypeError(
|
|
280
|
+
"function and variable are required when ode is not an operator"
|
|
281
|
+
)
|
|
282
|
+
operator = LinearDifferentialOperator.from_ode(ode, function, variable)
|
|
283
|
+
if not operator.is_homogeneous:
|
|
284
|
+
raise ValueError("Frobenius analysis requires a homogeneous equation")
|
|
285
|
+
substitutions = assumption_substitutions(assumptions)
|
|
286
|
+
if substitutions:
|
|
287
|
+
operator = LinearDifferentialOperator(
|
|
288
|
+
operator.variable,
|
|
289
|
+
operator.function,
|
|
290
|
+
tuple(sp.cancel(c.subs(substitutions)) for c in operator.coefficients),
|
|
291
|
+
sp.cancel(operator.inhomogeneous.subs(substitutions)),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
from .singularities import ODESingularityKind, classify_ode_point
|
|
295
|
+
|
|
296
|
+
local = classify_ode_point(operator, point=point, assumptions=assumptions)
|
|
297
|
+
if local.kind is not ODESingularityKind.REGULAR:
|
|
298
|
+
raise ValueError("Frobenius analysis requires a regular singular point")
|
|
299
|
+
|
|
300
|
+
r = sp.Symbol("r")
|
|
301
|
+
b = _regularized_coefficients(operator, sp.sympify(point))
|
|
302
|
+
indicial = _indicial_polynomial(b, operator.variable, sp.sympify(point), r)
|
|
303
|
+
roots = _root_data(indicial, r)
|
|
304
|
+
branches = tuple(
|
|
305
|
+
_branch(
|
|
306
|
+
b,
|
|
307
|
+
operator.variable,
|
|
308
|
+
sp.sympify(point),
|
|
309
|
+
indicial,
|
|
310
|
+
r,
|
|
311
|
+
root,
|
|
312
|
+
multiplicity,
|
|
313
|
+
terms,
|
|
314
|
+
assumptions,
|
|
315
|
+
)
|
|
316
|
+
for root, multiplicity in roots
|
|
317
|
+
)
|
|
318
|
+
diagnostics: list[ReductionDiagnostic] = []
|
|
319
|
+
for branch in branches:
|
|
320
|
+
for order in branch.obstructed_orders:
|
|
321
|
+
diagnostics.append(
|
|
322
|
+
ReductionDiagnostic(
|
|
323
|
+
stage="frobenius",
|
|
324
|
+
code="logarithmic-obstruction",
|
|
325
|
+
message=f"pure-power recurrence is obstructed at order {order}",
|
|
326
|
+
order=order,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
if branch.multiplicity > 1:
|
|
330
|
+
diagnostics.append(
|
|
331
|
+
ReductionDiagnostic(
|
|
332
|
+
stage="frobenius",
|
|
333
|
+
code="repeated-indicial-root",
|
|
334
|
+
message=f"indicial root has multiplicity {branch.multiplicity}",
|
|
335
|
+
rank=branch.multiplicity,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return FrobeniusAnalysis(
|
|
340
|
+
point=sp.sympify(point),
|
|
341
|
+
operator=operator,
|
|
342
|
+
indicial_polynomial=indicial,
|
|
343
|
+
indicial_variable=r,
|
|
344
|
+
root_multiplicities=roots,
|
|
345
|
+
resonances=_resonances(roots),
|
|
346
|
+
branches=branches,
|
|
347
|
+
terms=terms,
|
|
348
|
+
diagnostics=tuple(diagnostics),
|
|
349
|
+
)
|