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/newton.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Differential Newton polygons for scalar linear ODEs.
|
|
2
|
+
|
|
3
|
+
The convention used here attaches the point ``(j, v(a_j) - j)`` to the term
|
|
4
|
+
``a_j D**j`` of a local operator ``L = sum_j a_j D**j``, where ``v`` is the
|
|
5
|
+
local order in a uniformizing coordinate ``h``. Positive lower-edge slopes
|
|
6
|
+
are the Newton/Katz irregularity slopes. A slope ``rho > 0`` gives a leading
|
|
7
|
+
logarithmic derivative of order ``h**(-(rho + 1))``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from itertools import pairwise
|
|
14
|
+
from math import lcm
|
|
15
|
+
|
|
16
|
+
import sympy as sp
|
|
17
|
+
|
|
18
|
+
from ._local import local_coordinate
|
|
19
|
+
from ._power_simplify import analytic_powsimp
|
|
20
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
21
|
+
from .operator import LinearDifferentialOperator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class LocalizedOperator:
|
|
26
|
+
"""An operator expressed in a local coordinate at a finite point or infinity."""
|
|
27
|
+
|
|
28
|
+
original_operator: LinearDifferentialOperator
|
|
29
|
+
point: sp.Expr
|
|
30
|
+
operator: LinearDifferentialOperator
|
|
31
|
+
coordinate: sp.Expr
|
|
32
|
+
local_variable: sp.Symbol
|
|
33
|
+
|
|
34
|
+
def to_original(self, expression: sp.Expr) -> sp.Expr:
|
|
35
|
+
"""Rewrite a local expression in the original independent variable."""
|
|
36
|
+
|
|
37
|
+
expression = sp.sympify(expression)
|
|
38
|
+
x = self.original_operator.variable
|
|
39
|
+
if self.point == sp.oo:
|
|
40
|
+
return sp.simplify(expression.subs(self.local_variable, 1 / x))
|
|
41
|
+
return sp.simplify(expression.subs(self.local_variable, x - self.point))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class DifferentialNewtonPoint:
|
|
46
|
+
"""One point ``(j, v(a_j)-j)`` of the differential Newton polygon."""
|
|
47
|
+
|
|
48
|
+
derivative_order: int
|
|
49
|
+
coefficient_valuation: sp.Rational
|
|
50
|
+
height: sp.Rational
|
|
51
|
+
leading_coefficient: sp.Expr
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class DifferentialNewtonEdge:
|
|
56
|
+
"""One compact edge of the lower differential Newton polygon."""
|
|
57
|
+
|
|
58
|
+
left: DifferentialNewtonPoint
|
|
59
|
+
right: DifferentialNewtonPoint
|
|
60
|
+
points: tuple[DifferentialNewtonPoint, ...]
|
|
61
|
+
slope: sp.Rational
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def horizontal_length(self) -> int:
|
|
65
|
+
return self.right.derivative_order - self.left.derivative_order
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def irregularity_slope(self) -> sp.Rational:
|
|
69
|
+
return sp.Rational(max(sp.S.Zero, self.slope))
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_irregular(self) -> bool:
|
|
73
|
+
return bool(self.slope > 0)
|
|
74
|
+
|
|
75
|
+
def characteristic_polynomial(self, symbol: sp.Symbol | None = None) -> sp.Expr:
|
|
76
|
+
"""Return the edge characteristic polynomial in the logarithmic derivative."""
|
|
77
|
+
|
|
78
|
+
z = symbol or sp.Symbol("lambda")
|
|
79
|
+
return sp.factor(
|
|
80
|
+
sp.expand(
|
|
81
|
+
sum(
|
|
82
|
+
point.leading_coefficient * z**point.derivative_order
|
|
83
|
+
for point in self.points
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class NewtonSlopePiece:
|
|
91
|
+
"""One slope block of the differential-module Newton filtration.
|
|
92
|
+
|
|
93
|
+
``multiplicity`` is the horizontal Newton length carried by the slope.
|
|
94
|
+
Nonpositive polygon slopes are collected into the regular slope-zero block.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
slope: sp.Rational
|
|
98
|
+
multiplicity: int
|
|
99
|
+
ramification_index: int
|
|
100
|
+
edges: tuple[DifferentialNewtonEdge, ...] = ()
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def irregular(self) -> bool:
|
|
104
|
+
"""Return whether this filtration piece has positive irregular slope."""
|
|
105
|
+
|
|
106
|
+
return bool(self.slope > 0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class SlopeFiltration:
|
|
111
|
+
"""Newton slope filtration of a localized scalar differential module."""
|
|
112
|
+
|
|
113
|
+
point: sp.Expr
|
|
114
|
+
rank: int
|
|
115
|
+
pieces: tuple[NewtonSlopePiece, ...]
|
|
116
|
+
katz_rank: sp.Rational
|
|
117
|
+
poincare_rank: sp.Rational
|
|
118
|
+
irregularity: sp.Rational
|
|
119
|
+
ramification_index: int
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def slopes(self) -> tuple[sp.Rational, ...]:
|
|
123
|
+
"""Return slopes with horizontal multiplicity, in nondecreasing order."""
|
|
124
|
+
|
|
125
|
+
return tuple(
|
|
126
|
+
slope
|
|
127
|
+
for piece in self.pieces
|
|
128
|
+
for slope in (piece.slope,) * piece.multiplicity
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def irregular_pieces(self) -> tuple[NewtonSlopePiece, ...]:
|
|
133
|
+
"""Return the positive-slope pieces of the filtration."""
|
|
134
|
+
|
|
135
|
+
return tuple(piece for piece in self.pieces if piece.irregular)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class DifferentialNewtonPolygon:
|
|
140
|
+
"""Lower Newton polygon of a localized scalar differential operator."""
|
|
141
|
+
|
|
142
|
+
point: sp.Expr
|
|
143
|
+
localized: LocalizedOperator
|
|
144
|
+
points: tuple[DifferentialNewtonPoint, ...]
|
|
145
|
+
vertices: tuple[DifferentialNewtonPoint, ...]
|
|
146
|
+
edges: tuple[DifferentialNewtonEdge, ...]
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def irregular_edges(self) -> tuple[DifferentialNewtonEdge, ...]:
|
|
150
|
+
return tuple(edge for edge in self.edges if edge.is_irregular)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_irregular(self) -> bool:
|
|
154
|
+
return bool(self.irregular_edges)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def slopes(self) -> tuple[sp.Rational, ...]:
|
|
158
|
+
"""Return Newton slopes with horizontal multiplicity.
|
|
159
|
+
|
|
160
|
+
Negative lower-hull slopes belong to the regular part and are reported
|
|
161
|
+
as slope zero in the differential-module filtration.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
return slope_filtration(self).slopes
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def poincare_rank(self) -> sp.Rational:
|
|
168
|
+
"""Return the Poincare rank of the natural Euler-scaled companion system.
|
|
169
|
+
|
|
170
|
+
This presentation rank is gauge dependent. The invariant rational
|
|
171
|
+
highest Newton slope is :attr:`katz_rank` (also called the
|
|
172
|
+
Poincare--Katz rank).
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
return _euler_companion_poincare_rank(self)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def slope_filtration(self) -> SlopeFiltration:
|
|
179
|
+
"""Return the slope filtration represented by this Newton polygon."""
|
|
180
|
+
|
|
181
|
+
return slope_filtration(self)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def katz_rank(self) -> sp.Rational:
|
|
185
|
+
if not self.irregular_edges:
|
|
186
|
+
return sp.S.Zero
|
|
187
|
+
return max(edge.irregularity_slope for edge in self.irregular_edges)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def irregularity(self) -> sp.Rational:
|
|
191
|
+
return sp.simplify(
|
|
192
|
+
sum(
|
|
193
|
+
edge.horizontal_length * edge.irregularity_slope
|
|
194
|
+
for edge in self.irregular_edges
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def ramification_index(self) -> int:
|
|
200
|
+
denominators = [int(edge.irregularity_slope.q) for edge in self.irregular_edges]
|
|
201
|
+
result = 1
|
|
202
|
+
for denominator in denominators:
|
|
203
|
+
result = lcm(result, denominator)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def localize_operator(
|
|
208
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
209
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
210
|
+
variable: sp.Symbol | None = None,
|
|
211
|
+
*,
|
|
212
|
+
point: sp.Expr = 0,
|
|
213
|
+
) -> LocalizedOperator:
|
|
214
|
+
"""Express an operator in the shared coordinate vanishing at ``point``."""
|
|
215
|
+
local = local_coordinate(ode, function, variable, point=point)
|
|
216
|
+
return LocalizedOperator(
|
|
217
|
+
local.original_operator,
|
|
218
|
+
local.point,
|
|
219
|
+
local.operator,
|
|
220
|
+
local.variable,
|
|
221
|
+
local.variable,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def local_order_and_leading_coefficient(
|
|
226
|
+
expression: sp.Expr,
|
|
227
|
+
variable: sp.Symbol,
|
|
228
|
+
) -> tuple[sp.Rational, sp.Expr]:
|
|
229
|
+
"""Return finite local order and its leading coefficient at ``variable = 0``.
|
|
230
|
+
|
|
231
|
+
Integer valuations cover meromorphic coefficients. Rational valuations are
|
|
232
|
+
also accepted so that Puiseux-local coefficients can participate in the
|
|
233
|
+
Newton polygon. Essential/oscillatory behavior with no finite rational
|
|
234
|
+
valuation is rejected rather than assigned a misleading polygon point.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
expression = sp.cancel(sp.together(sp.sympify(expression)))
|
|
238
|
+
if expression == 0:
|
|
239
|
+
raise ValueError("the zero coefficient has no finite Newton valuation")
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
lead = analytic_powsimp(expression.as_leading_term(variable))
|
|
243
|
+
exponent = sp.sympify(lead.as_powers_dict().get(variable, 0))
|
|
244
|
+
if exponent.is_Rational:
|
|
245
|
+
valuation = sp.Rational(exponent)
|
|
246
|
+
coefficient = sp.simplify(
|
|
247
|
+
sp.limit(expression / variable**valuation, variable, 0)
|
|
248
|
+
)
|
|
249
|
+
if not coefficient.has(sp.oo, -sp.oo, sp.zoo, sp.nan) and coefficient != 0:
|
|
250
|
+
return valuation, coefficient
|
|
251
|
+
except SYMBOLIC_FAILURES:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
# Rational-function fallback avoids relying on series heuristics.
|
|
255
|
+
try:
|
|
256
|
+
num, den = sp.fraction(expression)
|
|
257
|
+
pn = sp.Poly(num, variable)
|
|
258
|
+
pd = sp.Poly(den, variable)
|
|
259
|
+
|
|
260
|
+
def multiplicity(poly: sp.Poly) -> int:
|
|
261
|
+
count = 0
|
|
262
|
+
q = poly
|
|
263
|
+
factor = sp.Poly(variable, variable)
|
|
264
|
+
while q.degree() >= 1:
|
|
265
|
+
quotient, remainder = divmod(q, factor)
|
|
266
|
+
if not remainder.is_zero:
|
|
267
|
+
break
|
|
268
|
+
count += 1
|
|
269
|
+
q = quotient
|
|
270
|
+
return count
|
|
271
|
+
|
|
272
|
+
valuation = sp.Rational(multiplicity(pn) - multiplicity(pd))
|
|
273
|
+
coefficient = sp.simplify(
|
|
274
|
+
sp.limit(expression / variable**valuation, variable, 0)
|
|
275
|
+
)
|
|
276
|
+
if not coefficient.has(sp.oo, -sp.oo, sp.zoo, sp.nan) and coefficient != 0:
|
|
277
|
+
return valuation, coefficient
|
|
278
|
+
except SYMBOLIC_FAILURES:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
raise NotImplementedError(
|
|
282
|
+
f"could not determine a finite rational local valuation for coefficient {expression!s}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _cross(
|
|
287
|
+
a: DifferentialNewtonPoint,
|
|
288
|
+
b: DifferentialNewtonPoint,
|
|
289
|
+
c: DifferentialNewtonPoint,
|
|
290
|
+
) -> sp.Expr:
|
|
291
|
+
return sp.expand(
|
|
292
|
+
(b.derivative_order - a.derivative_order) * (c.height - a.height)
|
|
293
|
+
- (b.height - a.height) * (c.derivative_order - a.derivative_order)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _edge_slope(a: DifferentialNewtonPoint, b: DifferentialNewtonPoint) -> sp.Rational:
|
|
298
|
+
return sp.Rational(b.height - a.height, b.derivative_order - a.derivative_order)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def differential_newton_polygon(
|
|
302
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
303
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
304
|
+
variable: sp.Symbol | None = None,
|
|
305
|
+
*,
|
|
306
|
+
point: sp.Expr = 0,
|
|
307
|
+
) -> DifferentialNewtonPolygon:
|
|
308
|
+
"""Construct the lower differential Newton polygon at ``point``.
|
|
309
|
+
|
|
310
|
+
Zero coefficients are omitted. Multiplying the complete operator by a
|
|
311
|
+
nonzero scalar coefficient translates all valuations vertically and hence
|
|
312
|
+
leaves slopes, Katz rank, ramification, and formal exponential data intact.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
localized = localize_operator(ode, function, variable, point=point)
|
|
316
|
+
if not localized.operator.is_homogeneous:
|
|
317
|
+
raise ValueError("differential Newton polygons require a homogeneous ODE")
|
|
318
|
+
|
|
319
|
+
points: list[DifferentialNewtonPoint] = []
|
|
320
|
+
for j, coefficient in enumerate(localized.operator.coefficients):
|
|
321
|
+
if coefficient == 0:
|
|
322
|
+
continue
|
|
323
|
+
valuation, leading = local_order_and_leading_coefficient(
|
|
324
|
+
coefficient, localized.local_variable
|
|
325
|
+
)
|
|
326
|
+
points.append(DifferentialNewtonPoint(j, valuation, valuation - j, leading))
|
|
327
|
+
|
|
328
|
+
if len(points) < 2:
|
|
329
|
+
raise ValueError("at least two nonzero derivative coefficients are required")
|
|
330
|
+
|
|
331
|
+
hull: list[DifferentialNewtonPoint] = []
|
|
332
|
+
for point_data in points:
|
|
333
|
+
while len(hull) >= 2:
|
|
334
|
+
cross = sp.simplify(_cross(hull[-2], hull[-1], point_data))
|
|
335
|
+
if cross.is_nonpositive is True:
|
|
336
|
+
hull.pop()
|
|
337
|
+
continue
|
|
338
|
+
if cross.is_positive is True:
|
|
339
|
+
break
|
|
340
|
+
raise NotImplementedError(
|
|
341
|
+
"could not order symbolic Newton-polygon valuations"
|
|
342
|
+
)
|
|
343
|
+
hull.append(point_data)
|
|
344
|
+
|
|
345
|
+
edges: list[DifferentialNewtonEdge] = []
|
|
346
|
+
for left, right in pairwise(hull):
|
|
347
|
+
slope = _edge_slope(left, right)
|
|
348
|
+
on_edge = tuple(
|
|
349
|
+
p
|
|
350
|
+
for p in points
|
|
351
|
+
if left.derivative_order <= p.derivative_order <= right.derivative_order
|
|
352
|
+
and sp.simplify(
|
|
353
|
+
(p.height - left.height)
|
|
354
|
+
* (right.derivative_order - left.derivative_order)
|
|
355
|
+
- (right.height - left.height)
|
|
356
|
+
* (p.derivative_order - left.derivative_order)
|
|
357
|
+
)
|
|
358
|
+
== 0
|
|
359
|
+
)
|
|
360
|
+
edges.append(DifferentialNewtonEdge(left, right, on_edge, slope))
|
|
361
|
+
|
|
362
|
+
return DifferentialNewtonPolygon(
|
|
363
|
+
point=sp.sympify(point),
|
|
364
|
+
localized=localized,
|
|
365
|
+
points=tuple(points),
|
|
366
|
+
vertices=tuple(hull),
|
|
367
|
+
edges=tuple(edges),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _euler_companion_poincare_rank(
|
|
372
|
+
polygon: DifferentialNewtonPolygon,
|
|
373
|
+
) -> sp.Rational:
|
|
374
|
+
"""Return the pole rank of the natural Euler-scaled companion system."""
|
|
375
|
+
|
|
376
|
+
operator = polygon.localized.operator.normalized()
|
|
377
|
+
h = polygon.localized.local_variable
|
|
378
|
+
rank = sp.S.Zero
|
|
379
|
+
for derivative_order in range(operator.order):
|
|
380
|
+
coefficient = sp.cancel(
|
|
381
|
+
sp.together(
|
|
382
|
+
h ** (operator.order - derivative_order)
|
|
383
|
+
* operator.coefficients[derivative_order]
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
if coefficient == 0:
|
|
387
|
+
continue
|
|
388
|
+
valuation, _ = local_order_and_leading_coefficient(coefficient, h)
|
|
389
|
+
rank = max(rank, -valuation)
|
|
390
|
+
return sp.Rational(rank)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def slope_filtration(
|
|
394
|
+
polygon_or_ode: DifferentialNewtonPolygon
|
|
395
|
+
| sp.Expr
|
|
396
|
+
| sp.Equality
|
|
397
|
+
| LinearDifferentialOperator,
|
|
398
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
399
|
+
variable: sp.Symbol | None = None,
|
|
400
|
+
*,
|
|
401
|
+
point: sp.Expr = 0,
|
|
402
|
+
) -> SlopeFiltration:
|
|
403
|
+
"""Return the Newton slope filtration at ``point``.
|
|
404
|
+
|
|
405
|
+
The horizontal length of each lower-hull edge is its slope multiplicity.
|
|
406
|
+
Every nonpositive edge contributes to the regular slope-zero piece; any
|
|
407
|
+
rank not represented to the left of the first nonzero derivative
|
|
408
|
+
coefficient is also regular. Positive rational slopes are retained exactly.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
polygon = (
|
|
412
|
+
polygon_or_ode
|
|
413
|
+
if isinstance(polygon_or_ode, DifferentialNewtonPolygon)
|
|
414
|
+
else differential_newton_polygon(
|
|
415
|
+
polygon_or_ode, function, variable, point=point
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
by_slope: dict[sp.Rational, list[DifferentialNewtonEdge]] = {}
|
|
419
|
+
multiplicities: dict[sp.Rational, int] = {}
|
|
420
|
+
represented = 0
|
|
421
|
+
for edge in polygon.edges:
|
|
422
|
+
slope = sp.Rational(max(sp.S.Zero, edge.slope))
|
|
423
|
+
by_slope.setdefault(slope, []).append(edge)
|
|
424
|
+
multiplicities[slope] = multiplicities.get(slope, 0) + edge.horizontal_length
|
|
425
|
+
represented += edge.horizontal_length
|
|
426
|
+
|
|
427
|
+
residual_regular = polygon.localized.operator.order - represented
|
|
428
|
+
if residual_regular < 0:
|
|
429
|
+
raise ValueError("Newton edge lengths exceed the differential-operator rank")
|
|
430
|
+
if residual_regular:
|
|
431
|
+
multiplicities[sp.S.Zero] = multiplicities.get(sp.S.Zero, 0) + residual_regular
|
|
432
|
+
by_slope.setdefault(sp.S.Zero, [])
|
|
433
|
+
|
|
434
|
+
pieces = tuple(
|
|
435
|
+
NewtonSlopePiece(
|
|
436
|
+
slope=slope,
|
|
437
|
+
multiplicity=multiplicities[slope],
|
|
438
|
+
ramification_index=int(slope.q) if slope > 0 else 1,
|
|
439
|
+
edges=tuple(by_slope[slope]),
|
|
440
|
+
)
|
|
441
|
+
for slope in sorted(multiplicities, key=sp.default_sort_key)
|
|
442
|
+
)
|
|
443
|
+
return SlopeFiltration(
|
|
444
|
+
point=polygon.point,
|
|
445
|
+
rank=polygon.localized.operator.order,
|
|
446
|
+
pieces=pieces,
|
|
447
|
+
katz_rank=polygon.katz_rank,
|
|
448
|
+
poincare_rank=polygon.poincare_rank,
|
|
449
|
+
irregularity=polygon.irregularity,
|
|
450
|
+
ramification_index=polygon.ramification_index,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def newton_slopes(
|
|
455
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
456
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
457
|
+
variable: sp.Symbol | None = None,
|
|
458
|
+
*,
|
|
459
|
+
point: sp.Expr = 0,
|
|
460
|
+
) -> tuple[sp.Rational, ...]:
|
|
461
|
+
"""Return the differential-module Newton slopes with multiplicity."""
|
|
462
|
+
|
|
463
|
+
return slope_filtration(ode, function, variable, point=point).slopes
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def katz_rank(
|
|
467
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
468
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
469
|
+
variable: sp.Symbol | None = None,
|
|
470
|
+
*,
|
|
471
|
+
point: sp.Expr = 0,
|
|
472
|
+
) -> sp.Rational:
|
|
473
|
+
"""Return the invariant rational Katz rank at ``point``."""
|
|
474
|
+
|
|
475
|
+
return differential_newton_polygon(ode, function, variable, point=point).katz_rank
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def poincare_rank(
|
|
479
|
+
polygon_or_ode: DifferentialNewtonPolygon
|
|
480
|
+
| sp.Expr
|
|
481
|
+
| sp.Equality
|
|
482
|
+
| LinearDifferentialOperator,
|
|
483
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
484
|
+
variable: sp.Symbol | None = None,
|
|
485
|
+
*,
|
|
486
|
+
point: sp.Expr = 0,
|
|
487
|
+
) -> sp.Rational:
|
|
488
|
+
"""Return the Poincare rank of the natural Euler-scaled companion system.
|
|
489
|
+
|
|
490
|
+
Unlike :func:`katz_rank`, this is a rank of a specified system
|
|
491
|
+
presentation and may decrease after meromorphic gauge reduction.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
polygon = (
|
|
495
|
+
polygon_or_ode
|
|
496
|
+
if isinstance(polygon_or_ode, DifferentialNewtonPolygon)
|
|
497
|
+
else differential_newton_polygon(
|
|
498
|
+
polygon_or_ode, function, variable, point=point
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
return _euler_companion_poincare_rank(polygon)
|
odeanalysis/operator.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Canonical scalar linear differential-operator representation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
from sympy.core.function import AppliedUndef
|
|
9
|
+
from sympy.solvers.deutils import ode_order
|
|
10
|
+
|
|
11
|
+
from ._assumptions import zero_status
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def as_ode_expression(ode: sp.Expr | sp.Equality) -> sp.Expr:
|
|
15
|
+
"""Return ``lhs-rhs`` for an ODE, expanded but otherwise unchanged."""
|
|
16
|
+
|
|
17
|
+
if isinstance(ode, sp.Equality):
|
|
18
|
+
return sp.expand(ode.lhs - ode.rhs)
|
|
19
|
+
return sp.expand(sp.sympify(ode))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def function_class(function: sp.FunctionClass | sp.Expr) -> sp.FunctionClass:
|
|
23
|
+
"""Normalize ``y`` or ``y(x)`` to its undefined SymPy function class."""
|
|
24
|
+
|
|
25
|
+
if isinstance(function, sp.FunctionClass):
|
|
26
|
+
return function
|
|
27
|
+
if isinstance(function, AppliedUndef):
|
|
28
|
+
return function.func
|
|
29
|
+
if getattr(function, "is_Function", False) and hasattr(function, "func"):
|
|
30
|
+
return function.func
|
|
31
|
+
raise TypeError("function must be an undefined SymPy function such as y or y(x)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LinearDifferentialOperator:
|
|
36
|
+
"""A scalar linear ODE represented by coefficient functions.
|
|
37
|
+
|
|
38
|
+
``coefficients[j]`` multiplies the ``j``-th derivative. The representation
|
|
39
|
+
retains an optional inhomogeneous remainder, but structural singularity and
|
|
40
|
+
Frobenius analysis concern the homogeneous operator only.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
variable: sp.Symbol
|
|
44
|
+
function: sp.FunctionClass
|
|
45
|
+
coefficients: tuple[sp.Expr, ...]
|
|
46
|
+
inhomogeneous: sp.Expr = sp.S.Zero
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def order(self) -> int:
|
|
50
|
+
return len(self.coefficients) - 1
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def leading_coefficient(self) -> sp.Expr:
|
|
54
|
+
return self.coefficients[-1]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def homogeneous_expression(self) -> sp.Expr:
|
|
58
|
+
yx = self.function(self.variable)
|
|
59
|
+
return sp.expand(
|
|
60
|
+
sum(
|
|
61
|
+
self.coefficients[j] * sp.diff(yx, self.variable, j)
|
|
62
|
+
for j in range(self.order + 1)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def expression(self) -> sp.Expr:
|
|
68
|
+
return sp.expand(self.homogeneous_expression + self.inhomogeneous)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_homogeneous(self) -> bool:
|
|
72
|
+
return self.inhomogeneous == 0
|
|
73
|
+
|
|
74
|
+
def normalized(self) -> LinearDifferentialOperator:
|
|
75
|
+
"""Return the monic operator obtained by division by its leading coefficient."""
|
|
76
|
+
|
|
77
|
+
lead = self.leading_coefficient
|
|
78
|
+
coeffs = (
|
|
79
|
+
*tuple(sp.cancel(sp.together(c / lead)) for c in self.coefficients[:-1]),
|
|
80
|
+
sp.S.One,
|
|
81
|
+
)
|
|
82
|
+
rhs = sp.cancel(sp.together(self.inhomogeneous / lead))
|
|
83
|
+
return LinearDifferentialOperator(self.variable, self.function, coeffs, rhs)
|
|
84
|
+
|
|
85
|
+
def coefficient(self, derivative_order: int) -> sp.Expr:
|
|
86
|
+
return self.coefficients[derivative_order]
|
|
87
|
+
|
|
88
|
+
def to_sympy_holonomic_operator(self):
|
|
89
|
+
"""Convert to SymPy's holonomic differential-operator type when polynomial.
|
|
90
|
+
|
|
91
|
+
The conversion is an adapter, not the package's
|
|
92
|
+
canonical representation: SymPy's holonomic algebra is excellent for
|
|
93
|
+
polynomial-coefficient operator arithmetic, while ``odeanalysis`` also
|
|
94
|
+
needs rational/meromorphic coefficients and local singularity metadata.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
if not all(
|
|
98
|
+
sp.sympify(c).is_polynomial(self.variable) for c in self.coefficients
|
|
99
|
+
):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"SymPy holonomic conversion requires polynomial coefficients"
|
|
102
|
+
)
|
|
103
|
+
from sympy.holonomic.holonomic import DifferentialOperators
|
|
104
|
+
|
|
105
|
+
ring, dx = DifferentialOperators(sp.EX.old_poly_ring(self.variable), "Dx")
|
|
106
|
+
_ = ring
|
|
107
|
+
result = 0
|
|
108
|
+
for j, coefficient in enumerate(self.coefficients):
|
|
109
|
+
result += coefficient * dx**j
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def reciprocal_transform(
|
|
113
|
+
self,
|
|
114
|
+
new_function: sp.FunctionClass | sp.Expr,
|
|
115
|
+
new_variable: sp.Symbol,
|
|
116
|
+
) -> LinearDifferentialOperator:
|
|
117
|
+
"""Transform the equation under ``x = 1/t`` and re-extract its operator."""
|
|
118
|
+
|
|
119
|
+
g = function_class(new_function)
|
|
120
|
+
yx = self.function(self.variable)
|
|
121
|
+
ut = g(new_variable)
|
|
122
|
+
replacements: dict[sp.Expr, sp.Expr] = {yx: ut}
|
|
123
|
+
current = ut
|
|
124
|
+
for k in range(1, self.order + 1):
|
|
125
|
+
current = sp.expand(-(new_variable**2) * sp.diff(current, new_variable))
|
|
126
|
+
replacements[sp.diff(yx, self.variable, k)] = current
|
|
127
|
+
transformed = self.expression.xreplace(replacements).subs(
|
|
128
|
+
self.variable, 1 / new_variable
|
|
129
|
+
)
|
|
130
|
+
transformed = sp.factor(sp.together(transformed))
|
|
131
|
+
return LinearDifferentialOperator.from_ode(transformed, g, new_variable)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_ode(
|
|
135
|
+
cls,
|
|
136
|
+
ode: sp.Expr | sp.Equality,
|
|
137
|
+
function: sp.FunctionClass | sp.Expr,
|
|
138
|
+
variable: sp.Symbol,
|
|
139
|
+
) -> LinearDifferentialOperator:
|
|
140
|
+
"""Extract a canonical scalar linear operator from a SymPy ODE.
|
|
141
|
+
|
|
142
|
+
SymPy's ``ode_order`` is used as the authoritative order detector. We
|
|
143
|
+
then use a polynomial representation in ``y, y', ..., y^(n)`` to verify
|
|
144
|
+
linearity and extract coefficients exactly.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
equation = as_ode_expression(ode)
|
|
148
|
+
f = function_class(function)
|
|
149
|
+
yx = f(variable)
|
|
150
|
+
order = int(ode_order(equation, yx))
|
|
151
|
+
if order < 1:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"equation must contain at least one derivative of the dependent function"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
gens = [yx] + [sp.diff(yx, variable, k) for k in range(1, order + 1)]
|
|
157
|
+
try:
|
|
158
|
+
poly = sp.Poly(equation, *gens)
|
|
159
|
+
except sp.PolynomialError as exc:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"equation must be polynomial and linear in y and its derivatives"
|
|
162
|
+
) from exc
|
|
163
|
+
if poly.total_degree() > 1:
|
|
164
|
+
raise ValueError("ODE analysis requires a scalar linear ODE")
|
|
165
|
+
|
|
166
|
+
coeffs = tuple(sp.cancel(equation.coeff(gens[k])) for k in range(order + 1))
|
|
167
|
+
if any(c.has(*gens) for c in coeffs):
|
|
168
|
+
raise ValueError("ODE coefficients must not depend on y or its derivatives")
|
|
169
|
+
homogeneous = sp.expand(sum(coeffs[k] * gens[k] for k in range(order + 1)))
|
|
170
|
+
remainder = sp.cancel(sp.together(equation - homogeneous))
|
|
171
|
+
if zero_status(coeffs[-1]) is True:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
"could not determine a nonzero leading derivative coefficient"
|
|
174
|
+
)
|
|
175
|
+
return cls(
|
|
176
|
+
variable=variable, function=f, coefficients=coeffs, inhomogeneous=remainder
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _coerce_linear_operator(
|
|
181
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
182
|
+
function: sp.FunctionClass | sp.Expr | None,
|
|
183
|
+
variable: sp.Symbol | None,
|
|
184
|
+
) -> LinearDifferentialOperator:
|
|
185
|
+
"""Return an operator, constructing it from an ODE expression when needed."""
|
|
186
|
+
|
|
187
|
+
if isinstance(ode, LinearDifferentialOperator):
|
|
188
|
+
return ode
|
|
189
|
+
if function is None or variable is None:
|
|
190
|
+
raise TypeError(
|
|
191
|
+
"function and variable are required when ode is not an operator"
|
|
192
|
+
)
|
|
193
|
+
return LinearDifferentialOperator.from_ode(ode, function, variable)
|