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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Parameter-aware local analysis of scalar linear ODEs."""
|
|
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
|
+
from semialg import implies, is_satisfiable, parametric_cad
|
|
10
|
+
|
|
11
|
+
from ._assumptions import assumption_substitutions
|
|
12
|
+
from .frobenius import FrobeniusAnalysis, frobenius_analysis
|
|
13
|
+
from .operator import LinearDifferentialOperator, _coerce_linear_operator
|
|
14
|
+
from .singularities import ODESingularity, ODESingularityKind, classify_ode_point
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class LocalAnalysisStratum:
|
|
19
|
+
"""One parameter region with a stable local ODE classification."""
|
|
20
|
+
|
|
21
|
+
condition: sp.Expr
|
|
22
|
+
singularity: ODESingularity
|
|
23
|
+
frobenius: FrobeniusAnalysis | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ResonanceStratum:
|
|
28
|
+
"""Finite resonance condition for a quadratic indicial family."""
|
|
29
|
+
|
|
30
|
+
condition: sp.Expr
|
|
31
|
+
difference: int | None
|
|
32
|
+
resonant: bool | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ParameterizedLocalAnalysis:
|
|
37
|
+
"""Finite stratification of parameter-dependent local behavior."""
|
|
38
|
+
|
|
39
|
+
point: sp.Expr
|
|
40
|
+
assumptions: sp.Expr
|
|
41
|
+
strata: tuple[LocalAnalysisStratum, ...]
|
|
42
|
+
exhaustive: bool
|
|
43
|
+
resonance_conditions: tuple[sp.Expr, ...] = ()
|
|
44
|
+
resonance_strata: tuple[ResonanceStratum, ...] = ()
|
|
45
|
+
|
|
46
|
+
def select(self, assumptions: sp.Expr | bool = True) -> LocalAnalysisStratum | None:
|
|
47
|
+
query = normalize_assumptions(sp.And(self.assumptions, assumptions))
|
|
48
|
+
parameters = tuple(sorted(query.free_symbols, key=sp.default_sort_key))
|
|
49
|
+
matches = [
|
|
50
|
+
stratum
|
|
51
|
+
for stratum in self.strata
|
|
52
|
+
if implies(query, stratum.condition, parameters)
|
|
53
|
+
]
|
|
54
|
+
return matches[0] if len(matches) == 1 else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _local_operator(
|
|
58
|
+
operator: LinearDifferentialOperator, point: sp.Expr
|
|
59
|
+
) -> tuple[LinearDifferentialOperator, sp.Expr]:
|
|
60
|
+
if point != sp.oo:
|
|
61
|
+
return operator, point
|
|
62
|
+
t = sp.Dummy("t", positive=True)
|
|
63
|
+
u = sp.Function("_u")
|
|
64
|
+
return operator.reciprocal_transform(u, t), sp.S.Zero
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _valuation_atoms(
|
|
68
|
+
operator: LinearDifferentialOperator, point: sp.Expr
|
|
69
|
+
) -> tuple[sp.Expr, ...]:
|
|
70
|
+
"""Return parameter expressions whose vanishing can change pole orders."""
|
|
71
|
+
op, local_point = _local_operator(operator, point)
|
|
72
|
+
x = op.variable
|
|
73
|
+
h = sp.Dummy("h")
|
|
74
|
+
atoms: set[sp.Expr] = set()
|
|
75
|
+
normalized = op.normalized().coefficients
|
|
76
|
+
for j, coeff in enumerate(normalized[:-1]):
|
|
77
|
+
try:
|
|
78
|
+
local = sp.cancel(sp.together(coeff.subs(x, local_point + h)))
|
|
79
|
+
num, den = sp.fraction(local)
|
|
80
|
+
den_poly = sp.Poly(den, h)
|
|
81
|
+
den_val = next(
|
|
82
|
+
(k for k in range(den_poly.degree() + 1) if den_poly.nth(k) != 0), 0
|
|
83
|
+
)
|
|
84
|
+
# A cancellation matters to ordinary/regular/irregular classification
|
|
85
|
+
# only when the generic denominator order exceeds the Fuchs bound.
|
|
86
|
+
if den_val <= op.order - j:
|
|
87
|
+
continue
|
|
88
|
+
for poly_expr in (num,):
|
|
89
|
+
poly = sp.Poly(poly_expr, h)
|
|
90
|
+
# Only coefficients before the first structurally nonzero term
|
|
91
|
+
# can alter the local valuation.
|
|
92
|
+
for k in range(poly.degree() + 1):
|
|
93
|
+
c = sp.factor(poly.nth(k))
|
|
94
|
+
if c == 0:
|
|
95
|
+
continue
|
|
96
|
+
params = c.free_symbols - {x, h}
|
|
97
|
+
if params:
|
|
98
|
+
atoms.add(c)
|
|
99
|
+
if not params:
|
|
100
|
+
break
|
|
101
|
+
except (sp.PolynomialError, TypeError, ValueError):
|
|
102
|
+
continue
|
|
103
|
+
return tuple(sorted(atoms, key=sp.default_sort_key))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _indicial_atoms(
|
|
107
|
+
operator: LinearDifferentialOperator,
|
|
108
|
+
point: sp.Expr,
|
|
109
|
+
assumptions: sp.Expr,
|
|
110
|
+
) -> tuple[sp.Expr, ...]:
|
|
111
|
+
op, local_point = _local_operator(operator, point)
|
|
112
|
+
local = classify_ode_point(op, point=local_point, assumptions=assumptions)
|
|
113
|
+
poly = local.indicial_polynomial
|
|
114
|
+
if (
|
|
115
|
+
local.kind is not ODESingularityKind.REGULAR
|
|
116
|
+
or poly is None
|
|
117
|
+
or operator.order != 2
|
|
118
|
+
):
|
|
119
|
+
return ()
|
|
120
|
+
r = sp.Symbol("r")
|
|
121
|
+
try:
|
|
122
|
+
p = sp.Poly(poly, r)
|
|
123
|
+
disc = sp.factor(sp.discriminant(p.as_expr(), r))
|
|
124
|
+
except (sp.PolynomialError, TypeError, ValueError):
|
|
125
|
+
return ()
|
|
126
|
+
return (disc,) if disc.free_symbols else ()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class _ParameterCase:
|
|
131
|
+
condition: sp.Expr
|
|
132
|
+
signature: tuple[bool | None, ...]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _transition_signature(
|
|
136
|
+
premise: sp.Expr,
|
|
137
|
+
transition_polynomials: tuple[sp.Expr, ...],
|
|
138
|
+
parameters: tuple[sp.Symbol, ...],
|
|
139
|
+
) -> tuple[bool | None, ...]:
|
|
140
|
+
"""Return certified zero/nonzero facts for one semialgebraic cell."""
|
|
141
|
+
signature: list[bool | None] = []
|
|
142
|
+
for polynomial in transition_polynomials:
|
|
143
|
+
zero = sp.Eq(polynomial, 0, evaluate=False)
|
|
144
|
+
if implies(premise, zero, parameters):
|
|
145
|
+
signature.append(True)
|
|
146
|
+
elif implies(premise, sp.Ne(polynomial, 0, evaluate=False), parameters):
|
|
147
|
+
signature.append(False)
|
|
148
|
+
else:
|
|
149
|
+
signature.append(None)
|
|
150
|
+
return tuple(signature)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parameter_cases(
|
|
154
|
+
transition_polynomials: tuple[sp.Expr, ...],
|
|
155
|
+
assumptions: sp.Expr,
|
|
156
|
+
) -> tuple[tuple[_ParameterCase, ...], bool]:
|
|
157
|
+
"""Return ODE-signature-grouped semialgebraic cells."""
|
|
158
|
+
parameters = tuple(
|
|
159
|
+
sorted(
|
|
160
|
+
set().union(
|
|
161
|
+
*(poly.free_symbols for poly in transition_polynomials),
|
|
162
|
+
assumptions.free_symbols,
|
|
163
|
+
),
|
|
164
|
+
key=sp.default_sort_key,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
if not parameters:
|
|
168
|
+
cases = (
|
|
169
|
+
(_ParameterCase(sp.S.true, ()),) if is_satisfiable(assumptions, ()) else ()
|
|
170
|
+
)
|
|
171
|
+
return cases, True
|
|
172
|
+
|
|
173
|
+
marker = (
|
|
174
|
+
sp.Ne(sp.prod(transition_polynomials), 0, evaluate=False)
|
|
175
|
+
if transition_polynomials
|
|
176
|
+
else sp.S.true
|
|
177
|
+
)
|
|
178
|
+
geometry = parametric_cad(
|
|
179
|
+
marker,
|
|
180
|
+
(),
|
|
181
|
+
parameters=parameters,
|
|
182
|
+
assumptions=assumptions,
|
|
183
|
+
output="result",
|
|
184
|
+
)
|
|
185
|
+
groups: dict[tuple[bool | None, ...], list[sp.Expr]] = {}
|
|
186
|
+
for case in geometry.cases:
|
|
187
|
+
premise = sp.And(assumptions, case.condition)
|
|
188
|
+
if not is_satisfiable(premise, parameters):
|
|
189
|
+
continue
|
|
190
|
+
signature = _transition_signature(premise, transition_polynomials, parameters)
|
|
191
|
+
groups.setdefault(signature, []).append(case.condition)
|
|
192
|
+
cases = tuple(
|
|
193
|
+
_ParameterCase(sp.Or(*cells), signature) for signature, cells in groups.items()
|
|
194
|
+
)
|
|
195
|
+
# A complete semialg result already certifies coverage of the supplied
|
|
196
|
+
# parameter domain; re-proving the disjunction with equivalent() is both
|
|
197
|
+
# redundant and substantially more expensive on larger decompositions.
|
|
198
|
+
return cases, geometry.status == "complete"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _case_assumptions(
|
|
202
|
+
case: _ParameterCase,
|
|
203
|
+
assumptions: sp.Expr,
|
|
204
|
+
transition_polynomials: tuple[sp.Expr, ...],
|
|
205
|
+
) -> sp.Expr:
|
|
206
|
+
"""Materialize the zero/nonzero facts already certified for a grouped cell."""
|
|
207
|
+
facts: list[sp.Expr] = [assumptions]
|
|
208
|
+
for polynomial, state in zip(transition_polynomials, case.signature, strict=True):
|
|
209
|
+
if state is True:
|
|
210
|
+
facts.append(sp.Eq(polynomial, 0, evaluate=False))
|
|
211
|
+
elif state is False:
|
|
212
|
+
facts.append(sp.Ne(polynomial, 0, evaluate=False))
|
|
213
|
+
return normalize_assumptions(sp.And(*facts))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class _LocalContext:
|
|
218
|
+
operator: LinearDifferentialOperator
|
|
219
|
+
point: sp.Expr
|
|
220
|
+
display_point: sp.Expr
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def build(
|
|
224
|
+
cls, operator: LinearDifferentialOperator, point: sp.Expr
|
|
225
|
+
) -> _LocalContext:
|
|
226
|
+
local_operator, local_point = _local_operator(operator, point)
|
|
227
|
+
return cls(local_operator, local_point, point)
|
|
228
|
+
|
|
229
|
+
def classify(self, assumptions: sp.Expr) -> ODESingularity:
|
|
230
|
+
local = classify_ode_point(
|
|
231
|
+
self.operator, point=self.point, assumptions=assumptions
|
|
232
|
+
)
|
|
233
|
+
if self.display_point != sp.oo:
|
|
234
|
+
return local
|
|
235
|
+
return ODESingularity(
|
|
236
|
+
point=sp.oo,
|
|
237
|
+
kind=local.kind,
|
|
238
|
+
order=local.order,
|
|
239
|
+
normalized_coefficients=local.normalized_coefficients,
|
|
240
|
+
pole_orders=local.pole_orders,
|
|
241
|
+
indicial_polynomial=local.indicial_polynomial,
|
|
242
|
+
indicial_roots=local.indicial_roots,
|
|
243
|
+
transformed_equation=self.operator.expression,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def frobenius(self, assumptions: sp.Expr, terms: int) -> FrobeniusAnalysis | None:
|
|
247
|
+
substitutions = assumption_substitutions(assumptions)
|
|
248
|
+
operator = self.operator
|
|
249
|
+
if substitutions:
|
|
250
|
+
operator = LinearDifferentialOperator(
|
|
251
|
+
operator.variable,
|
|
252
|
+
operator.function,
|
|
253
|
+
tuple(c.subs(substitutions) for c in operator.coefficients),
|
|
254
|
+
operator.inhomogeneous.subs(substitutions),
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
return frobenius_analysis(
|
|
258
|
+
operator, point=self.point, terms=terms, assumptions=assumptions
|
|
259
|
+
)
|
|
260
|
+
except (ValueError, NotImplementedError):
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _finite_resonance_strata(
|
|
265
|
+
discriminant: sp.Expr | None,
|
|
266
|
+
assumptions: sp.Expr,
|
|
267
|
+
max_order: int,
|
|
268
|
+
) -> tuple[ResonanceStratum, ...]:
|
|
269
|
+
if discriminant is None or not discriminant.free_symbols:
|
|
270
|
+
return ()
|
|
271
|
+
parameters = tuple(
|
|
272
|
+
sorted(
|
|
273
|
+
set().union(discriminant.free_symbols, assumptions.free_symbols),
|
|
274
|
+
key=sp.default_sort_key,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
equations = tuple(
|
|
278
|
+
sp.Eq(discriminant, n * n, evaluate=False) for n in range(1, max_order + 1)
|
|
279
|
+
)
|
|
280
|
+
strata = [
|
|
281
|
+
ResonanceStratum(sp.And(assumptions, equation), n, True)
|
|
282
|
+
for n, equation in enumerate(equations, 1)
|
|
283
|
+
if is_satisfiable(sp.And(assumptions, equation), parameters)
|
|
284
|
+
]
|
|
285
|
+
if equations:
|
|
286
|
+
complement = sp.And(
|
|
287
|
+
assumptions,
|
|
288
|
+
*(
|
|
289
|
+
sp.Ne(discriminant, n * n, evaluate=False)
|
|
290
|
+
for n in range(1, max_order + 1)
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
if is_satisfiable(complement, parameters):
|
|
294
|
+
strata.append(ResonanceStratum(complement, None, None))
|
|
295
|
+
return tuple(strata)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def local_parameter_analysis(
|
|
299
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
300
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
301
|
+
variable: sp.Symbol | None = None,
|
|
302
|
+
point: sp.Expr = 0,
|
|
303
|
+
*,
|
|
304
|
+
terms: int = 6,
|
|
305
|
+
assumptions: sp.Expr | bool = True,
|
|
306
|
+
max_resonance_order: int = 4,
|
|
307
|
+
) -> ParameterizedLocalAnalysis:
|
|
308
|
+
"""Classify local behavior on finite parameter strata.
|
|
309
|
+
|
|
310
|
+
Strata are generated only from algebraic conditions that can change local
|
|
311
|
+
coefficient valuations or the multiplicity of a quadratic indicial
|
|
312
|
+
polynomial. Unresolved transcendental/integer resonances remain explicit
|
|
313
|
+
conditions rather than being guessed.
|
|
314
|
+
"""
|
|
315
|
+
operator = _coerce_linear_operator(ode, function, variable)
|
|
316
|
+
assumptions = normalize_assumptions(assumptions)
|
|
317
|
+
if max_resonance_order < 0:
|
|
318
|
+
raise ValueError("max_resonance_order must be nonnegative")
|
|
319
|
+
point = sp.sympify(point)
|
|
320
|
+
atoms = _valuation_atoms(operator, point)
|
|
321
|
+
atoms += tuple(
|
|
322
|
+
a for a in _indicial_atoms(operator, point, assumptions) if a not in atoms
|
|
323
|
+
)
|
|
324
|
+
# Resonance hypersurfaces are part of the same parameter geometry as
|
|
325
|
+
# valuation and repeated-root transitions.
|
|
326
|
+
op0, p0 = _local_operator(operator, point)
|
|
327
|
+
local0 = classify_ode_point(op0, point=p0, assumptions=assumptions)
|
|
328
|
+
discriminant = None
|
|
329
|
+
if (
|
|
330
|
+
local0.kind is ODESingularityKind.REGULAR
|
|
331
|
+
and local0.indicial_polynomial is not None
|
|
332
|
+
and operator.order == 2
|
|
333
|
+
):
|
|
334
|
+
r = sp.Symbol("r")
|
|
335
|
+
try:
|
|
336
|
+
discriminant = sp.factor(
|
|
337
|
+
sp.discriminant(sp.Poly(local0.indicial_polynomial, r).as_expr(), r)
|
|
338
|
+
)
|
|
339
|
+
except (sp.PolynomialError, TypeError, ValueError):
|
|
340
|
+
discriminant = None
|
|
341
|
+
transition_polynomials = tuple(dict.fromkeys(atoms))
|
|
342
|
+
cases, geometry_certified = _parameter_cases(transition_polynomials, assumptions)
|
|
343
|
+
strata: list[LocalAnalysisStratum] = []
|
|
344
|
+
context = _LocalContext.build(operator, point)
|
|
345
|
+
for case in cases:
|
|
346
|
+
local_assumptions = _case_assumptions(case, assumptions, transition_polynomials)
|
|
347
|
+
local = context.classify(local_assumptions)
|
|
348
|
+
frob = (
|
|
349
|
+
context.frobenius(local_assumptions, terms)
|
|
350
|
+
if local.kind is ODESingularityKind.REGULAR
|
|
351
|
+
else None
|
|
352
|
+
)
|
|
353
|
+
candidate = LocalAnalysisStratum(case.condition, local, frob)
|
|
354
|
+
for index, existing in enumerate(strata):
|
|
355
|
+
if (
|
|
356
|
+
existing.singularity == candidate.singularity
|
|
357
|
+
and existing.frobenius == candidate.frobenius
|
|
358
|
+
):
|
|
359
|
+
strata[index] = LocalAnalysisStratum(
|
|
360
|
+
sp.Or(existing.condition, case.condition), local, frob
|
|
361
|
+
)
|
|
362
|
+
break
|
|
363
|
+
else:
|
|
364
|
+
strata.append(candidate)
|
|
365
|
+
|
|
366
|
+
# Merge strata with identical local signatures only when no Frobenius
|
|
367
|
+
# degeneracy distinction would be lost.
|
|
368
|
+
resonance: list[sp.Expr] = []
|
|
369
|
+
for stratum in strata:
|
|
370
|
+
f = stratum.frobenius
|
|
371
|
+
if f is None or len(f.root_multiplicities) != 2:
|
|
372
|
+
continue
|
|
373
|
+
a, b = (root for root, _ in f.root_multiplicities)
|
|
374
|
+
resonance.append(sp.Contains(sp.simplify(a - b), sp.S.Integers))
|
|
375
|
+
resonance_strata = _finite_resonance_strata(
|
|
376
|
+
discriminant, assumptions, max_resonance_order
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return ParameterizedLocalAnalysis(
|
|
380
|
+
point=point,
|
|
381
|
+
assumptions=assumptions,
|
|
382
|
+
strata=tuple(strata),
|
|
383
|
+
exhaustive=geometry_certified,
|
|
384
|
+
resonance_conditions=tuple(dict.fromkeys(resonance)),
|
|
385
|
+
resonance_strata=resonance_strata,
|
|
386
|
+
)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Unified local singularity structure for scalar linear ODEs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
from funcprops import normalize_assumptions
|
|
9
|
+
|
|
10
|
+
from ._assumptions import zero_status
|
|
11
|
+
from ._local import LocalCoordinate, local_coordinate
|
|
12
|
+
from .formal_basis import (
|
|
13
|
+
FormalBasisError,
|
|
14
|
+
formal_monodromy,
|
|
15
|
+
logarithmic_frobenius_basis,
|
|
16
|
+
)
|
|
17
|
+
from .frobenius import FrobeniusAnalysis, frobenius_analysis
|
|
18
|
+
from .fuchsian import ApparentSingularityAnalysis, apparent_singularity_analysis
|
|
19
|
+
from .operator import LinearDifferentialOperator, _coerce_linear_operator
|
|
20
|
+
from .singularities import ODESingularity, ODESingularityKind, analyze_ode_singularities
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FrobeniusConvergence:
|
|
25
|
+
"""Convergence geometry certified from singularities of the ODE coefficients.
|
|
26
|
+
|
|
27
|
+
``radius`` is the coefficient-analyticity radius. It is not
|
|
28
|
+
presented as a maximal continuation radius for every solution: apparent
|
|
29
|
+
singularities or special solutions may continue farther.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
point: sp.Expr
|
|
33
|
+
radius: sp.Expr | None
|
|
34
|
+
nearest_singularities: tuple[sp.Expr, ...]
|
|
35
|
+
domain: sp.Expr | None
|
|
36
|
+
certified: bool
|
|
37
|
+
coordinate: sp.Symbol
|
|
38
|
+
solution_continuation_radius: sp.Expr | None = None
|
|
39
|
+
solution_continuation_certified: bool = False
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def coefficient_radius(self) -> sp.Expr | None:
|
|
43
|
+
return self.radius
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def coefficient_domain(self) -> sp.Expr | None:
|
|
47
|
+
return self.domain
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class FrobeniusLocalMonodromy:
|
|
52
|
+
"""Local monodromy derived from completed Frobenius data."""
|
|
53
|
+
|
|
54
|
+
point: sp.Expr
|
|
55
|
+
matrix: sp.ImmutableMatrix | None
|
|
56
|
+
certified: bool
|
|
57
|
+
logarithmic: bool | None
|
|
58
|
+
reason: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class SingularityPointStructure:
|
|
63
|
+
"""Unified evidence attached to one singular point."""
|
|
64
|
+
|
|
65
|
+
singularity: ODESingularity
|
|
66
|
+
frobenius: FrobeniusAnalysis | None
|
|
67
|
+
convergence: FrobeniusConvergence | None
|
|
68
|
+
apparent: ApparentSingularityAnalysis | None
|
|
69
|
+
monodromy: FrobeniusLocalMonodromy | None
|
|
70
|
+
local_coordinate: LocalCoordinate | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class SingularityStructure:
|
|
75
|
+
"""Unified finite/infinite singularity structure of a scalar linear ODE."""
|
|
76
|
+
|
|
77
|
+
operator: LinearDifferentialOperator
|
|
78
|
+
assumptions: sp.Expr
|
|
79
|
+
points: tuple[SingularityPointStructure, ...]
|
|
80
|
+
|
|
81
|
+
def at(self, point: sp.Expr) -> SingularityPointStructure | None:
|
|
82
|
+
point = sp.sympify(point)
|
|
83
|
+
return next(
|
|
84
|
+
(item for item in self.points if item.singularity.point == point), None
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _coefficient_singularities(
|
|
89
|
+
operator: LinearDifferentialOperator,
|
|
90
|
+
) -> tuple[tuple[sp.Expr, ...], bool]:
|
|
91
|
+
"""Return finite coefficient singularities and whether the list is exhaustive."""
|
|
92
|
+
x = operator.variable
|
|
93
|
+
points: set[sp.Expr] = set()
|
|
94
|
+
exhaustive = True
|
|
95
|
+
for coeff in operator.normalized().coefficients[:-1]:
|
|
96
|
+
expr = sp.cancel(sp.together(coeff))
|
|
97
|
+
_, den = sp.fraction(expr)
|
|
98
|
+
try:
|
|
99
|
+
poly = sp.Poly(den, x)
|
|
100
|
+
except (sp.PolynomialError, TypeError, ValueError):
|
|
101
|
+
exhaustive = False
|
|
102
|
+
try:
|
|
103
|
+
singular = sp.singularities(expr, x)
|
|
104
|
+
except (NotImplementedError, ValueError):
|
|
105
|
+
continue
|
|
106
|
+
if isinstance(singular, sp.FiniteSet):
|
|
107
|
+
points.update(singular)
|
|
108
|
+
continue
|
|
109
|
+
if poly.degree() <= 0:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
roots = sp.roots(poly.as_expr(), x, cubics=False, quartics=False)
|
|
113
|
+
except (NotImplementedError, TypeError, ValueError, sp.PolynomialError):
|
|
114
|
+
roots = {}
|
|
115
|
+
if roots and sum(int(m) for m in roots.values()) == poly.degree():
|
|
116
|
+
points.update(roots)
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
all_roots = poly.all_roots()
|
|
120
|
+
except (NotImplementedError, TypeError, ValueError, sp.PolynomialError):
|
|
121
|
+
exhaustive = False
|
|
122
|
+
else:
|
|
123
|
+
if len(all_roots) == poly.degree():
|
|
124
|
+
points.update(all_roots)
|
|
125
|
+
else:
|
|
126
|
+
exhaustive = False
|
|
127
|
+
return tuple(sorted(points, key=sp.default_sort_key)), exhaustive
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def frobenius_convergence(
|
|
131
|
+
analysis: FrobeniusAnalysis,
|
|
132
|
+
*,
|
|
133
|
+
singular_points: tuple[sp.Expr, ...] | None = None,
|
|
134
|
+
) -> FrobeniusConvergence:
|
|
135
|
+
"""Return the coefficient-analyticity disk controlling a Frobenius series."""
|
|
136
|
+
operator = analysis.operator
|
|
137
|
+
x = operator.variable
|
|
138
|
+
point = analysis.point
|
|
139
|
+
if point == sp.oo:
|
|
140
|
+
return FrobeniusConvergence(point, None, (), None, False, x)
|
|
141
|
+
if singular_points is None:
|
|
142
|
+
points, exhaustive = _coefficient_singularities(operator)
|
|
143
|
+
else:
|
|
144
|
+
points, exhaustive = tuple(singular_points), True
|
|
145
|
+
others = tuple(p for p in points if zero_status(p - point) is not True)
|
|
146
|
+
if not others:
|
|
147
|
+
radius = sp.oo if exhaustive else None
|
|
148
|
+
domain = sp.S.true if radius is sp.oo else None
|
|
149
|
+
return FrobeniusConvergence(point, radius, (), domain, exhaustive, x)
|
|
150
|
+
distances = tuple(sp.simplify(sp.Abs(p - point)) for p in others)
|
|
151
|
+
radius = distances[0] if len(distances) == 1 else sp.Min(*distances)
|
|
152
|
+
nearest = tuple(
|
|
153
|
+
p
|
|
154
|
+
for p, distance in zip(others, distances, strict=True)
|
|
155
|
+
if zero_status(distance - radius) is True
|
|
156
|
+
)
|
|
157
|
+
domain = sp.Abs(x - point) < radius
|
|
158
|
+
return FrobeniusConvergence(point, radius, nearest, domain, exhaustive, x)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def frobenius_local_monodromy(analysis: FrobeniusAnalysis) -> FrobeniusLocalMonodromy:
|
|
162
|
+
"""Derive local monodromy from a completed Frobenius analysis."""
|
|
163
|
+
if (
|
|
164
|
+
not analysis.root_multiplicities
|
|
165
|
+
or len(analysis.roots) != analysis.operator.order
|
|
166
|
+
):
|
|
167
|
+
return FrobeniusLocalMonodromy(
|
|
168
|
+
analysis.point, None, False, None, "indicial roots are incomplete"
|
|
169
|
+
)
|
|
170
|
+
if not analysis.logarithm_may_be_required:
|
|
171
|
+
diag = [sp.exp(2 * sp.pi * sp.I * root) for root in analysis.roots]
|
|
172
|
+
return FrobeniusLocalMonodromy(
|
|
173
|
+
analysis.point,
|
|
174
|
+
sp.ImmutableMatrix(sp.diag(*diag)),
|
|
175
|
+
True,
|
|
176
|
+
False,
|
|
177
|
+
"pure-power Frobenius basis gives diagonal local monodromy",
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
basis = logarithmic_frobenius_basis(
|
|
181
|
+
analysis.operator, point=analysis.point, terms=max(analysis.terms, 8)
|
|
182
|
+
)
|
|
183
|
+
if not basis.complete or basis.dimension != analysis.operator.order:
|
|
184
|
+
raise FormalBasisError("incomplete logarithmic Frobenius basis")
|
|
185
|
+
monodromy = formal_monodromy(basis)
|
|
186
|
+
except (FormalBasisError, NotImplementedError, ValueError):
|
|
187
|
+
return FrobeniusLocalMonodromy(
|
|
188
|
+
analysis.point,
|
|
189
|
+
None,
|
|
190
|
+
False,
|
|
191
|
+
analysis.logarithm_required,
|
|
192
|
+
"a complete logarithmic Frobenius basis could not be certified",
|
|
193
|
+
)
|
|
194
|
+
matrix = monodromy.local_matrix
|
|
195
|
+
if matrix is None:
|
|
196
|
+
return FrobeniusLocalMonodromy(
|
|
197
|
+
analysis.point,
|
|
198
|
+
None,
|
|
199
|
+
False,
|
|
200
|
+
basis.has_logarithms,
|
|
201
|
+
"physical one-turn monodromy is unavailable on the ramified cover",
|
|
202
|
+
)
|
|
203
|
+
return FrobeniusLocalMonodromy(
|
|
204
|
+
analysis.point,
|
|
205
|
+
sp.ImmutableMatrix(matrix),
|
|
206
|
+
True,
|
|
207
|
+
basis.has_logarithms,
|
|
208
|
+
"completed logarithmic Frobenius basis determines local monodromy",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _regular_point_structure(
|
|
213
|
+
original: LinearDifferentialOperator,
|
|
214
|
+
singularity: ODESingularity,
|
|
215
|
+
assumptions: sp.Expr,
|
|
216
|
+
terms: int,
|
|
217
|
+
finite_points: tuple[sp.Expr, ...],
|
|
218
|
+
finite_points_certified: bool,
|
|
219
|
+
) -> SingularityPointStructure:
|
|
220
|
+
point = singularity.point
|
|
221
|
+
coordinate = local_coordinate(original, point=point)
|
|
222
|
+
local_operator = coordinate.operator if point == sp.oo else original
|
|
223
|
+
local_point = sp.S.Zero if point == sp.oo else point
|
|
224
|
+
try:
|
|
225
|
+
frob = frobenius_analysis(
|
|
226
|
+
local_operator, point=local_point, terms=terms, assumptions=assumptions
|
|
227
|
+
)
|
|
228
|
+
except (NotImplementedError, ValueError):
|
|
229
|
+
return SingularityPointStructure(
|
|
230
|
+
singularity, None, None, None, None, coordinate
|
|
231
|
+
)
|
|
232
|
+
monodromy = frobenius_local_monodromy(frob)
|
|
233
|
+
if point == sp.oo:
|
|
234
|
+
local_points, exhaustive = _coefficient_singularities(local_operator)
|
|
235
|
+
convergence = frobenius_convergence(frob, singular_points=local_points)
|
|
236
|
+
convergence = replace(
|
|
237
|
+
convergence, point=sp.oo, certified=convergence.certified and exhaustive
|
|
238
|
+
)
|
|
239
|
+
monodromy = replace(monodromy, point=sp.oo)
|
|
240
|
+
else:
|
|
241
|
+
convergence = frobenius_convergence(frob, singular_points=finite_points)
|
|
242
|
+
if not finite_points_certified:
|
|
243
|
+
convergence = replace(convergence, certified=False)
|
|
244
|
+
apparent = apparent_singularity_analysis(
|
|
245
|
+
original,
|
|
246
|
+
point=point,
|
|
247
|
+
assumptions=assumptions,
|
|
248
|
+
frobenius=frob,
|
|
249
|
+
monodromy=monodromy,
|
|
250
|
+
)
|
|
251
|
+
return SingularityPointStructure(
|
|
252
|
+
singularity, frob, convergence, apparent, monodromy, coordinate
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def singularity_structure(
|
|
257
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
258
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
259
|
+
variable: sp.Symbol | None = None,
|
|
260
|
+
*,
|
|
261
|
+
assumptions: sp.Expr | bool = True,
|
|
262
|
+
terms: int = 8,
|
|
263
|
+
include_infinity: bool = True,
|
|
264
|
+
) -> SingularityStructure:
|
|
265
|
+
"""Build one coherent local-analysis object for every singular point."""
|
|
266
|
+
operator = _coerce_linear_operator(ode, function, variable)
|
|
267
|
+
assumptions = normalize_assumptions(assumptions)
|
|
268
|
+
global_data = analyze_ode_singularities(
|
|
269
|
+
operator, include_infinity=include_infinity, assumptions=assumptions
|
|
270
|
+
)
|
|
271
|
+
singularities = list(global_data.finite)
|
|
272
|
+
if global_data.infinity is not None:
|
|
273
|
+
singularities.append(global_data.infinity)
|
|
274
|
+
finite_points, finite_points_certified = _coefficient_singularities(operator)
|
|
275
|
+
items: list[SingularityPointStructure] = []
|
|
276
|
+
for singularity in singularities:
|
|
277
|
+
if singularity.kind is ODESingularityKind.REGULAR:
|
|
278
|
+
items.append(
|
|
279
|
+
_regular_point_structure(
|
|
280
|
+
operator,
|
|
281
|
+
singularity,
|
|
282
|
+
assumptions,
|
|
283
|
+
terms,
|
|
284
|
+
finite_points,
|
|
285
|
+
finite_points_certified,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
items.append(SingularityPointStructure(singularity, None, None, None, None))
|
|
290
|
+
return SingularityStructure(operator, assumptions, tuple(items))
|