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/levelt.py
ADDED
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"""Levelt structure for regular-singular and exponential formal blocks.
|
|
2
|
+
|
|
3
|
+
The public scalar entry point combines the exponential-block decomposition
|
|
4
|
+
with the logarithmic formal basis. Each isolated exponential block is then
|
|
5
|
+
represented in Levelt form by a semisimple exponent matrix, a commuting
|
|
6
|
+
nilpotent logarithmic part, exponent classes modulo integers, and formal
|
|
7
|
+
monodromy on the common uniformizing cover.
|
|
8
|
+
|
|
9
|
+
For matrix systems, :func:`levelt_reduce_regular_singular` performs a full
|
|
10
|
+
truncated Levelt reduction. It first removes nonresonant positive-degree
|
|
11
|
+
terms by the homological equations
|
|
12
|
+
|
|
13
|
+
H -> R H - H R - n H.
|
|
14
|
+
|
|
15
|
+
Integer-difference resonances are then absorbed by a diagonal integer Levelt
|
|
16
|
+
transformation ``diag(t**k_i)`` that shifts every exponent in a congruence
|
|
17
|
+
class to a common representative. Resonant terms thereby move into the
|
|
18
|
+
residue, where they become the nilpotent logarithmic part. A final
|
|
19
|
+
nonresonant cleanup produces a connection with constant Levelt residue modulo
|
|
20
|
+
the requested truncation order.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from math import lcm
|
|
27
|
+
|
|
28
|
+
import sympy as sp
|
|
29
|
+
|
|
30
|
+
from ._formal_gauge import constant_series as _constant_series
|
|
31
|
+
from ._formal_gauge import formal_gauge_transform as _formal_gauge_transform
|
|
32
|
+
from ._moser import MoserReduction, moser_reduce
|
|
33
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
34
|
+
from .diagnostics import ReductionDiagnostic
|
|
35
|
+
from .formal_basis import (
|
|
36
|
+
FormalBasisError,
|
|
37
|
+
FormalLogarithmicBasis,
|
|
38
|
+
FormalMonodromy,
|
|
39
|
+
FormalSolutionBlock,
|
|
40
|
+
formal_logarithmic_basis,
|
|
41
|
+
formal_monodromy,
|
|
42
|
+
)
|
|
43
|
+
from .matrix_series import MatrixLaurentSeries
|
|
44
|
+
from .operator import LinearDifferentialOperator
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LeveltReductionError(NotImplementedError):
|
|
48
|
+
"""Raised when an exact Levelt structure cannot be certified."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class LeveltExponentClass:
|
|
53
|
+
"""Indices whose exponents are congruent modulo the integers."""
|
|
54
|
+
|
|
55
|
+
representative: sp.Expr
|
|
56
|
+
exponents: tuple[sp.Expr, ...]
|
|
57
|
+
indices: tuple[int, ...]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class LeveltResonantTerm:
|
|
62
|
+
"""One positive-degree term retained by the Levelt homological equation."""
|
|
63
|
+
|
|
64
|
+
power: int
|
|
65
|
+
coefficient: sp.ImmutableMatrix
|
|
66
|
+
homological_rank: int
|
|
67
|
+
nullity: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class RegularSingularLeveltReduction:
|
|
72
|
+
"""Truncated exact Levelt reduction of a Fuchsian system.
|
|
73
|
+
|
|
74
|
+
``jordan_residue`` is the residue before integer exponent shifts.
|
|
75
|
+
``levelt_residue`` is the final normalized residue after resonant terms
|
|
76
|
+
have been absorbed. The latter splits as ``semisimple_residue`` plus
|
|
77
|
+
``nilpotent_residue`` and directly determines formal cover monodromy.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
original_connection: MatrixLaurentSeries
|
|
81
|
+
transformed_connection: MatrixLaurentSeries
|
|
82
|
+
gauge: MatrixLaurentSeries
|
|
83
|
+
residue: sp.ImmutableMatrix
|
|
84
|
+
jordan_basis: sp.ImmutableMatrix
|
|
85
|
+
jordan_residue: sp.ImmutableMatrix
|
|
86
|
+
levelt_residue: sp.ImmutableMatrix
|
|
87
|
+
semisimple_residue: sp.ImmutableMatrix
|
|
88
|
+
nilpotent_residue: sp.ImmutableMatrix
|
|
89
|
+
exponent_classes: tuple[LeveltExponentClass, ...]
|
|
90
|
+
integer_shifts: tuple[int, ...]
|
|
91
|
+
integer_gauge: MatrixLaurentSeries
|
|
92
|
+
absorbed_resonant_terms: tuple[LeveltResonantTerm, ...]
|
|
93
|
+
resonant_terms: tuple[LeveltResonantTerm, ...]
|
|
94
|
+
max_power: int
|
|
95
|
+
complete: bool
|
|
96
|
+
limitation: str | None = None
|
|
97
|
+
diagnostics: tuple[ReductionDiagnostic, ...] = ()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def cover_monodromy(self) -> sp.ImmutableMatrix:
|
|
101
|
+
"""Formal monodromy on one full turn of the uniformizing variable."""
|
|
102
|
+
|
|
103
|
+
return sp.ImmutableMatrix(
|
|
104
|
+
(2 * sp.pi * sp.I * sp.Matrix(self.levelt_residue)).exp()
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def cover_monodromy_if_nonresonant(self) -> sp.ImmutableMatrix | None:
|
|
109
|
+
"""Backward-compatible alias once no positive-degree terms remain."""
|
|
110
|
+
|
|
111
|
+
if self.resonant_terms:
|
|
112
|
+
return None
|
|
113
|
+
return self.cover_monodromy
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class LeveltExponentialBlock:
|
|
118
|
+
"""One exponential block in formal Levelt-Turrittin form.
|
|
119
|
+
|
|
120
|
+
On its cover the block has the structural form
|
|
121
|
+
|
|
122
|
+
``H(t) * exp(Q(t^-1)) * t**Lambda * exp(N*log(t))``.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
index: int
|
|
126
|
+
exponential_polynomial: sp.Expr
|
|
127
|
+
local_exponential_polynomial: sp.Expr
|
|
128
|
+
ramification_index: int
|
|
129
|
+
dimension: int
|
|
130
|
+
semisimple_exponent: sp.ImmutableMatrix
|
|
131
|
+
nilpotent_exponent: sp.ImmutableMatrix
|
|
132
|
+
formal_exponent_matrix: sp.ImmutableMatrix
|
|
133
|
+
exponent_classes: tuple[LeveltExponentClass, ...]
|
|
134
|
+
cover_monodromy: sp.ImmutableMatrix
|
|
135
|
+
has_logarithms: bool
|
|
136
|
+
basis_block: FormalSolutionBlock
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def normal_form_factorization(
|
|
140
|
+
self,
|
|
141
|
+
) -> tuple[sp.Expr, sp.ImmutableMatrix, sp.ImmutableMatrix]:
|
|
142
|
+
return (
|
|
143
|
+
self.exponential_polynomial,
|
|
144
|
+
self.semisimple_exponent,
|
|
145
|
+
self.nilpotent_exponent,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class LeveltStructure:
|
|
151
|
+
"""Full scalar formal exponential/Levelt block structure."""
|
|
152
|
+
|
|
153
|
+
point: sp.Expr
|
|
154
|
+
local_coordinate: sp.Symbol
|
|
155
|
+
blocks: tuple[LeveltExponentialBlock, ...]
|
|
156
|
+
basis: FormalLogarithmicBasis
|
|
157
|
+
monodromy: FormalMonodromy
|
|
158
|
+
ramification_index: int
|
|
159
|
+
complete: bool
|
|
160
|
+
limitation: str | None = None
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def dimension(self) -> int:
|
|
164
|
+
return sum(block.dimension for block in self.blocks)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def cover_monodromy(self) -> sp.Matrix:
|
|
168
|
+
return self.monodromy.cover_matrix
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def local_monodromy(self) -> sp.Matrix | None:
|
|
172
|
+
return self.monodromy.local_matrix
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _same_mod_integer(left: sp.Expr, right: sp.Expr) -> bool:
|
|
176
|
+
diff = sp.simplify(left - right)
|
|
177
|
+
return bool(diff.is_integer is True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _exponent_classes(
|
|
181
|
+
exponents: tuple[sp.Expr, ...],
|
|
182
|
+
) -> tuple[LeveltExponentClass, ...]:
|
|
183
|
+
groups: list[list[tuple[int, sp.Expr]]] = []
|
|
184
|
+
for index, exponent in enumerate(exponents):
|
|
185
|
+
for group in groups:
|
|
186
|
+
if _same_mod_integer(exponent, group[0][1]):
|
|
187
|
+
group.append((index, exponent))
|
|
188
|
+
break
|
|
189
|
+
else:
|
|
190
|
+
groups.append([(index, exponent)])
|
|
191
|
+
return tuple(
|
|
192
|
+
LeveltExponentClass(
|
|
193
|
+
representative=sp.simplify(group[0][1]),
|
|
194
|
+
exponents=tuple(sp.simplify(value) for _, value in group),
|
|
195
|
+
indices=tuple(index for index, _ in group),
|
|
196
|
+
)
|
|
197
|
+
for group in groups
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _vec(matrix: sp.MatrixBase) -> sp.Matrix:
|
|
202
|
+
matrix = sp.Matrix(matrix)
|
|
203
|
+
return sp.Matrix(list(matrix))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _unvec(vector: sp.MatrixBase, rows: int, cols: int) -> sp.Matrix:
|
|
207
|
+
values = list(sp.Matrix(vector))
|
|
208
|
+
return sp.Matrix(rows, cols, values)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _homological_matrix(residue: sp.MatrixBase, n: int) -> sp.Matrix:
|
|
212
|
+
residue = sp.Matrix(residue)
|
|
213
|
+
size = residue.rows
|
|
214
|
+
columns: list[sp.Matrix] = []
|
|
215
|
+
for index in range(size * size):
|
|
216
|
+
basis = sp.zeros(size)
|
|
217
|
+
row, col = divmod(index, size)
|
|
218
|
+
basis[row, col] = 1
|
|
219
|
+
image = residue * basis - basis * residue - n * basis
|
|
220
|
+
columns.append(_vec(image))
|
|
221
|
+
return sp.Matrix.hstack(*columns)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _image_complement(matrix: sp.MatrixBase) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
225
|
+
"""Return independent image-column indices and coordinate complement indices."""
|
|
226
|
+
|
|
227
|
+
matrix = sp.Matrix(matrix)
|
|
228
|
+
_, pivots = matrix.rref()
|
|
229
|
+
image_pivots = tuple(int(i) for i in pivots)
|
|
230
|
+
basis = [matrix[:, i] for i in image_pivots]
|
|
231
|
+
current = sp.Matrix.hstack(*basis) if basis else sp.zeros(matrix.rows, 0)
|
|
232
|
+
rank = current.rank()
|
|
233
|
+
complement: list[int] = []
|
|
234
|
+
for i in range(matrix.rows):
|
|
235
|
+
e = sp.eye(matrix.rows)[:, i]
|
|
236
|
+
candidate = current.row_join(e)
|
|
237
|
+
new_rank = candidate.rank()
|
|
238
|
+
if new_rank > rank:
|
|
239
|
+
complement.append(i)
|
|
240
|
+
current = candidate
|
|
241
|
+
rank = new_rank
|
|
242
|
+
if rank == matrix.rows:
|
|
243
|
+
break
|
|
244
|
+
if rank != matrix.rows:
|
|
245
|
+
raise LeveltReductionError(
|
|
246
|
+
"could not construct a complement to the homological image"
|
|
247
|
+
)
|
|
248
|
+
return image_pivots, tuple(complement)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _homological_reduce_coefficient(
|
|
252
|
+
residue: sp.MatrixBase,
|
|
253
|
+
coefficient: sp.MatrixBase,
|
|
254
|
+
n: int,
|
|
255
|
+
) -> tuple[sp.Matrix, sp.Matrix, int, int]:
|
|
256
|
+
"""Solve C + L_n(H) = K with K in a deterministic exact complement."""
|
|
257
|
+
|
|
258
|
+
residue = sp.Matrix(residue)
|
|
259
|
+
coefficient = sp.Matrix(coefficient)
|
|
260
|
+
size = residue.rows
|
|
261
|
+
operator = _homological_matrix(residue, n)
|
|
262
|
+
image_pivots, complement_indices = _image_complement(operator)
|
|
263
|
+
image_basis = [operator[:, i] for i in image_pivots]
|
|
264
|
+
complement_basis = [sp.eye(size * size)[:, i] for i in complement_indices]
|
|
265
|
+
system = sp.Matrix.hstack(*(image_basis + [(-v) for v in complement_basis]))
|
|
266
|
+
rhs = -_vec(coefficient)
|
|
267
|
+
try:
|
|
268
|
+
solution = system.inv() * rhs
|
|
269
|
+
except SYMBOLIC_FAILURES as exc:
|
|
270
|
+
raise LeveltReductionError(
|
|
271
|
+
"could not solve the Levelt homological decomposition"
|
|
272
|
+
) from exc
|
|
273
|
+
h_vec = sp.zeros(size * size, 1)
|
|
274
|
+
for position, domain_index in enumerate(image_pivots):
|
|
275
|
+
h_vec[domain_index] = sp.simplify(solution[position])
|
|
276
|
+
k_vec = sp.zeros(size * size, 1)
|
|
277
|
+
offset = len(image_pivots)
|
|
278
|
+
for position, coordinate_index in enumerate(complement_indices):
|
|
279
|
+
k_vec[coordinate_index] = sp.simplify(solution[offset + position])
|
|
280
|
+
h = _unvec(h_vec, size, size)
|
|
281
|
+
k = _unvec(k_vec, size, size)
|
|
282
|
+
rank = int(operator.rank())
|
|
283
|
+
return h, k, rank, size * size - rank
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _homological_normalize(
|
|
287
|
+
connection: MatrixLaurentSeries,
|
|
288
|
+
residue: sp.MatrixBase,
|
|
289
|
+
*,
|
|
290
|
+
max_power: int,
|
|
291
|
+
) -> tuple[MatrixLaurentSeries, MatrixLaurentSeries, tuple[LeveltResonantTerm, ...]]:
|
|
292
|
+
"""Remove homological-image terms and retain exact resonant complements."""
|
|
293
|
+
|
|
294
|
+
variable = connection.variable
|
|
295
|
+
current = connection
|
|
296
|
+
gauge = MatrixLaurentSeries.identity(variable, connection.rows)
|
|
297
|
+
resonant: list[LeveltResonantTerm] = []
|
|
298
|
+
residue = sp.Matrix(residue)
|
|
299
|
+
for n in range(1, max_power + 2):
|
|
300
|
+
power = n - 1
|
|
301
|
+
coefficient = sp.Matrix(current.coefficient(power))
|
|
302
|
+
if coefficient.is_zero_matrix:
|
|
303
|
+
continue
|
|
304
|
+
h, _k, rank, nullity = _homological_reduce_coefficient(residue, coefficient, n)
|
|
305
|
+
if not h.is_zero_matrix:
|
|
306
|
+
step = MatrixLaurentSeries.from_mapping(
|
|
307
|
+
variable,
|
|
308
|
+
{0: sp.eye(connection.rows), n: h},
|
|
309
|
+
shape=connection.shape,
|
|
310
|
+
)
|
|
311
|
+
current = _formal_gauge_transform(current, step, max_power=max_power)
|
|
312
|
+
gauge = gauge.multiply(step, max_power=max_power + n)
|
|
313
|
+
retained = sp.Matrix(current.coefficient(power))
|
|
314
|
+
if not retained.is_zero_matrix:
|
|
315
|
+
resonant.append(
|
|
316
|
+
LeveltResonantTerm(
|
|
317
|
+
power=power,
|
|
318
|
+
coefficient=sp.ImmutableMatrix(retained),
|
|
319
|
+
homological_rank=rank,
|
|
320
|
+
nullity=nullity,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return current, gauge, tuple(resonant)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _integer_levelt_shifts(
|
|
327
|
+
exponents: tuple[sp.Expr, ...],
|
|
328
|
+
classes: tuple[LeveltExponentClass, ...],
|
|
329
|
+
) -> tuple[int, ...]:
|
|
330
|
+
"""Choose concrete integer shifts sending each class to its representative."""
|
|
331
|
+
|
|
332
|
+
shifts = [0] * len(exponents)
|
|
333
|
+
for cls in classes:
|
|
334
|
+
representative = sp.sympify(cls.representative)
|
|
335
|
+
for index in cls.indices:
|
|
336
|
+
difference = sp.simplify(exponents[index] - representative)
|
|
337
|
+
if difference.is_Integer:
|
|
338
|
+
shifts[index] = int(difference)
|
|
339
|
+
continue
|
|
340
|
+
if difference.is_integer is True:
|
|
341
|
+
raise LeveltReductionError(
|
|
342
|
+
"symbolic integer exponent differences are not representable "
|
|
343
|
+
"by MatrixLaurentSeries"
|
|
344
|
+
)
|
|
345
|
+
if difference != 0:
|
|
346
|
+
raise LeveltReductionError(
|
|
347
|
+
"could not certify an integer Levelt exponent shift"
|
|
348
|
+
)
|
|
349
|
+
return tuple(shifts)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _diagonal_integer_gauge(
|
|
353
|
+
variable: sp.Symbol, shifts: tuple[int, ...]
|
|
354
|
+
) -> MatrixLaurentSeries:
|
|
355
|
+
matrix = sp.diag(*(variable**shift for shift in shifts))
|
|
356
|
+
return MatrixLaurentSeries.from_matrix(matrix, variable)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _is_scalar_coefficient(matrix: sp.MatrixBase) -> bool:
|
|
360
|
+
matrix = sp.Matrix(matrix)
|
|
361
|
+
if matrix.rows != matrix.cols:
|
|
362
|
+
return False
|
|
363
|
+
scalar = matrix[0, 0] if matrix.rows else 0
|
|
364
|
+
return all(
|
|
365
|
+
sp.simplify(matrix[i, j] - (scalar if i == j else 0)) == 0
|
|
366
|
+
for i in range(matrix.rows)
|
|
367
|
+
for j in range(matrix.cols)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def levelt_reduce_regular_singular(
|
|
372
|
+
connection: MatrixLaurentSeries,
|
|
373
|
+
*,
|
|
374
|
+
max_power: int,
|
|
375
|
+
) -> RegularSingularLeveltReduction:
|
|
376
|
+
"""Return a truncated full Levelt reduction of a Fuchsian connection.
|
|
377
|
+
|
|
378
|
+
Positive-degree resonances are first exposed by the usual homological
|
|
379
|
+
equations. For each exponent class modulo the integers, a diagonal
|
|
380
|
+
meromorphic gauge ``diag(t**k_i)`` then shifts all exponents to the same
|
|
381
|
+
representative. A resonant coefficient in position ``(i,j)`` at
|
|
382
|
+
``t**(n-1)`` satisfies ``lambda_i-lambda_j=n``; the integer gauge changes
|
|
383
|
+
its power to ``-1``, so it becomes part of the Levelt residue instead of
|
|
384
|
+
remaining as an external positive-degree normal-form term.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
if max_power < 0:
|
|
388
|
+
raise ValueError("max_power must be nonnegative")
|
|
389
|
+
if connection.rows != connection.cols:
|
|
390
|
+
raise ValueError("Levelt reduction requires a square connection")
|
|
391
|
+
for power, coefficient in connection.terms:
|
|
392
|
+
if power < -1 and not _is_scalar_coefficient(coefficient):
|
|
393
|
+
raise LeveltReductionError(
|
|
394
|
+
"connection is still irregular; run Moser/exponential reduction first"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
variable = connection.variable
|
|
398
|
+
residue = sp.Matrix(connection.coefficient(-1))
|
|
399
|
+
try:
|
|
400
|
+
jordan_basis, _jordan = residue.jordan_form()
|
|
401
|
+
except SYMBOLIC_FAILURES as exc:
|
|
402
|
+
raise LeveltReductionError(
|
|
403
|
+
"could not construct the exact residue Jordan form"
|
|
404
|
+
) from exc
|
|
405
|
+
if sp.simplify(jordan_basis.det()) == 0:
|
|
406
|
+
raise LeveltReductionError("residue Jordan basis is singular")
|
|
407
|
+
|
|
408
|
+
constant = _constant_series(variable, jordan_basis)
|
|
409
|
+
jordan_connection = _formal_gauge_transform(
|
|
410
|
+
connection, constant, max_power=max_power
|
|
411
|
+
)
|
|
412
|
+
jordan_residue = sp.Matrix(jordan_connection.coefficient(-1))
|
|
413
|
+
exponents = tuple(
|
|
414
|
+
sp.simplify(jordan_residue[i, i]) for i in range(jordan_residue.rows)
|
|
415
|
+
)
|
|
416
|
+
classes = _exponent_classes(exponents)
|
|
417
|
+
shifts = _integer_levelt_shifts(exponents, classes)
|
|
418
|
+
spread = max(shifts, default=0) - min(shifts, default=0)
|
|
419
|
+
|
|
420
|
+
# Terms that an integer shear can move down to residue/negative order must
|
|
421
|
+
# be normalized *before* the shear. The spread is the largest possible
|
|
422
|
+
# power displacement between two matrix entries.
|
|
423
|
+
pre_order = max_power + spread
|
|
424
|
+
if pre_order != max_power:
|
|
425
|
+
jordan_connection = _formal_gauge_transform(
|
|
426
|
+
connection, constant, max_power=pre_order
|
|
427
|
+
)
|
|
428
|
+
pre_current, pre_gauge, absorbed = _homological_normalize(
|
|
429
|
+
jordan_connection, jordan_residue, max_power=pre_order
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
integer_gauge = _diagonal_integer_gauge(variable, shifts)
|
|
433
|
+
shifted = _formal_gauge_transform(pre_current, integer_gauge, max_power=max_power)
|
|
434
|
+
|
|
435
|
+
# A correct Levelt integer transform may leave scalar exponential pieces
|
|
436
|
+
# below -1, but no nonscalar irregular term. In the regular-singular
|
|
437
|
+
# setting even scalar terms should normally be absent; we retain the more
|
|
438
|
+
# invariant nonscalar certification here.
|
|
439
|
+
bad_irregular = [
|
|
440
|
+
power
|
|
441
|
+
for power, coefficient in shifted.terms
|
|
442
|
+
if power < -1 and not _is_scalar_coefficient(coefficient)
|
|
443
|
+
]
|
|
444
|
+
if bad_irregular:
|
|
445
|
+
limitation = (
|
|
446
|
+
"integer Levelt transformation produced an unresolved nonscalar "
|
|
447
|
+
f"irregular term at power {min(bad_irregular)}"
|
|
448
|
+
)
|
|
449
|
+
return RegularSingularLeveltReduction(
|
|
450
|
+
original_connection=connection,
|
|
451
|
+
transformed_connection=shifted,
|
|
452
|
+
gauge=constant.multiply(pre_gauge, max_power=pre_order + spread).multiply(
|
|
453
|
+
integer_gauge, max_power=pre_order + spread
|
|
454
|
+
),
|
|
455
|
+
residue=sp.ImmutableMatrix(residue),
|
|
456
|
+
jordan_basis=sp.ImmutableMatrix(jordan_basis),
|
|
457
|
+
jordan_residue=sp.ImmutableMatrix(jordan_residue),
|
|
458
|
+
levelt_residue=sp.ImmutableMatrix(shifted.coefficient(-1)),
|
|
459
|
+
semisimple_residue=sp.ImmutableMatrix(sp.zeros(connection.rows)),
|
|
460
|
+
nilpotent_residue=sp.ImmutableMatrix(sp.zeros(connection.rows)),
|
|
461
|
+
exponent_classes=classes,
|
|
462
|
+
integer_shifts=shifts,
|
|
463
|
+
integer_gauge=integer_gauge,
|
|
464
|
+
absorbed_resonant_terms=absorbed,
|
|
465
|
+
resonant_terms=(),
|
|
466
|
+
max_power=max_power,
|
|
467
|
+
complete=False,
|
|
468
|
+
limitation=limitation,
|
|
469
|
+
diagnostics=(
|
|
470
|
+
ReductionDiagnostic(
|
|
471
|
+
stage="levelt",
|
|
472
|
+
code="irregular-term-after-shift",
|
|
473
|
+
message=limitation,
|
|
474
|
+
power=min(bad_irregular),
|
|
475
|
+
),
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
levelt_residue = sp.Matrix(shifted.coefficient(-1))
|
|
480
|
+
post_current, post_gauge, remaining = _homological_normalize(
|
|
481
|
+
shifted, levelt_residue, max_power=max_power
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# After integer normalization all semisimple exponent differences inside
|
|
485
|
+
# a class are zero and differences between classes are nonintegral. Thus
|
|
486
|
+
# no positive-degree Levelt resonance should remain.
|
|
487
|
+
complete = not remaining
|
|
488
|
+
limitation = None
|
|
489
|
+
if remaining:
|
|
490
|
+
limitation = (
|
|
491
|
+
"positive-degree resonance remains after integer Levelt "
|
|
492
|
+
"normalization; the exponent-class split is incomplete"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
levelt_residue = sp.Matrix(post_current.coefficient(-1))
|
|
496
|
+
normalized_exponents = tuple(
|
|
497
|
+
sp.simplify(exponents[i] - shifts[i]) for i in range(len(exponents))
|
|
498
|
+
)
|
|
499
|
+
semisimple = sp.diag(*normalized_exponents)
|
|
500
|
+
nilpotent = (levelt_residue - semisimple).applyfunc(sp.simplify)
|
|
501
|
+
if not (semisimple * nilpotent - nilpotent * semisimple).is_zero_matrix:
|
|
502
|
+
complete = False
|
|
503
|
+
limitation = (
|
|
504
|
+
"the normalized residue did not split into commuting semisimple "
|
|
505
|
+
"and nilpotent Levelt parts"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
gauge_order = pre_order + spread + max_power + 2
|
|
509
|
+
gauge = constant.multiply(pre_gauge, max_power=gauge_order)
|
|
510
|
+
gauge = gauge.multiply(integer_gauge, max_power=gauge_order)
|
|
511
|
+
gauge = gauge.multiply(post_gauge, max_power=gauge_order)
|
|
512
|
+
|
|
513
|
+
diagnostics: list[ReductionDiagnostic] = []
|
|
514
|
+
for term in remaining:
|
|
515
|
+
diagnostics.append(
|
|
516
|
+
ReductionDiagnostic(
|
|
517
|
+
stage="levelt",
|
|
518
|
+
code="positive-degree-resonance",
|
|
519
|
+
message="homological normalization retained a resonant coefficient",
|
|
520
|
+
power=term.power,
|
|
521
|
+
rank=term.homological_rank,
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
if not complete and not diagnostics and limitation:
|
|
525
|
+
diagnostics.append(
|
|
526
|
+
ReductionDiagnostic(
|
|
527
|
+
stage="levelt",
|
|
528
|
+
code="noncommuting-residue-split",
|
|
529
|
+
message=limitation,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
return RegularSingularLeveltReduction(
|
|
534
|
+
original_connection=connection,
|
|
535
|
+
transformed_connection=post_current,
|
|
536
|
+
gauge=gauge,
|
|
537
|
+
residue=sp.ImmutableMatrix(residue),
|
|
538
|
+
jordan_basis=sp.ImmutableMatrix(jordan_basis),
|
|
539
|
+
jordan_residue=sp.ImmutableMatrix(jordan_residue),
|
|
540
|
+
levelt_residue=sp.ImmutableMatrix(levelt_residue),
|
|
541
|
+
semisimple_residue=sp.ImmutableMatrix(semisimple),
|
|
542
|
+
nilpotent_residue=sp.ImmutableMatrix(nilpotent),
|
|
543
|
+
exponent_classes=_exponent_classes(normalized_exponents),
|
|
544
|
+
integer_shifts=shifts,
|
|
545
|
+
integer_gauge=integer_gauge,
|
|
546
|
+
absorbed_resonant_terms=absorbed,
|
|
547
|
+
resonant_terms=remaining,
|
|
548
|
+
max_power=max_power,
|
|
549
|
+
complete=complete,
|
|
550
|
+
limitation=limitation,
|
|
551
|
+
diagnostics=tuple(diagnostics),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def reduce_to_fuchsian(
|
|
556
|
+
connection: MatrixLaurentSeries,
|
|
557
|
+
*,
|
|
558
|
+
max_power: int,
|
|
559
|
+
max_steps: int = 8,
|
|
560
|
+
) -> MoserReduction:
|
|
561
|
+
"""Public convenience wrapper for the Moser/shearing stage."""
|
|
562
|
+
|
|
563
|
+
return moser_reduce(connection, max_power=max_power, max_steps=max_steps)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _nilpotent_logarithm(unipotent: sp.MatrixBase) -> sp.Matrix:
|
|
567
|
+
unipotent = sp.Matrix(unipotent)
|
|
568
|
+
x = unipotent - sp.eye(unipotent.rows)
|
|
569
|
+
result = sp.zeros(unipotent.rows)
|
|
570
|
+
power = sp.eye(unipotent.rows)
|
|
571
|
+
for k in range(1, unipotent.rows + 1):
|
|
572
|
+
power = power * x
|
|
573
|
+
if power.is_zero_matrix:
|
|
574
|
+
break
|
|
575
|
+
result += sp.Rational((-1) ** (k + 1), k) * power
|
|
576
|
+
if not (x**unipotent.rows).is_zero_matrix:
|
|
577
|
+
raise LeveltReductionError("monodromy quotient is not certifiably unipotent")
|
|
578
|
+
return result.applyfunc(sp.simplify)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _block_levelt_data(
|
|
582
|
+
block: FormalSolutionBlock,
|
|
583
|
+
cover_monodromy: sp.MatrixBase,
|
|
584
|
+
index: int,
|
|
585
|
+
) -> LeveltExponentialBlock:
|
|
586
|
+
exponents = tuple(sp.simplify(v.ramified_exponent) for v in block.basis_vectors)
|
|
587
|
+
semisimple = sp.diag(*exponents) if exponents else sp.zeros(0)
|
|
588
|
+
classes = _exponent_classes(exponents)
|
|
589
|
+
cover = sp.Matrix(cover_monodromy)
|
|
590
|
+
|
|
591
|
+
# On a Levelt class all exp(2*pi*i*lambda) coincide. Remove the
|
|
592
|
+
# semisimple factor class-by-class, then take the finite nilpotent log.
|
|
593
|
+
nilpotent = sp.zeros(block.dimension)
|
|
594
|
+
for cls in classes:
|
|
595
|
+
indices = cls.indices
|
|
596
|
+
if not indices:
|
|
597
|
+
continue
|
|
598
|
+
scalar = sp.exp(2 * sp.pi * sp.I * cls.representative)
|
|
599
|
+
sub = cover.extract(indices, indices) / scalar
|
|
600
|
+
nsub = _nilpotent_logarithm(sub) / (2 * sp.pi * sp.I)
|
|
601
|
+
for a, i in enumerate(indices):
|
|
602
|
+
for b, j in enumerate(indices):
|
|
603
|
+
nilpotent[i, j] = sp.simplify(nsub[a, b])
|
|
604
|
+
formal_exponent = semisimple + nilpotent
|
|
605
|
+
return LeveltExponentialBlock(
|
|
606
|
+
index=index,
|
|
607
|
+
exponential_polynomial=block.exponential_polynomial,
|
|
608
|
+
local_exponential_polynomial=block.local_exponential_polynomial,
|
|
609
|
+
ramification_index=block.ramification_index,
|
|
610
|
+
dimension=block.dimension,
|
|
611
|
+
semisimple_exponent=sp.ImmutableMatrix(semisimple),
|
|
612
|
+
nilpotent_exponent=sp.ImmutableMatrix(nilpotent),
|
|
613
|
+
formal_exponent_matrix=sp.ImmutableMatrix(formal_exponent),
|
|
614
|
+
exponent_classes=classes,
|
|
615
|
+
cover_monodromy=sp.ImmutableMatrix(cover),
|
|
616
|
+
has_logarithms=block.has_logarithms,
|
|
617
|
+
basis_block=block,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def levelt_structure(
|
|
622
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
623
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
624
|
+
variable: sp.Symbol | None = None,
|
|
625
|
+
*,
|
|
626
|
+
point: sp.Expr = 0,
|
|
627
|
+
terms: int = 8,
|
|
628
|
+
max_branches: int = 64,
|
|
629
|
+
) -> LeveltStructure:
|
|
630
|
+
"""Return the completed scalar exponential/Levelt block structure.
|
|
631
|
+
|
|
632
|
+
The existing formal basis constructor supplies certified logarithmic
|
|
633
|
+
companions inside every isolated exponential block. The corresponding
|
|
634
|
+
cover monodromy determines the commuting nilpotent Levelt part exactly,
|
|
635
|
+
including integer-difference resonances that are invisible in the residue
|
|
636
|
+
alone.
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
basis = formal_logarithmic_basis(
|
|
641
|
+
ode,
|
|
642
|
+
function,
|
|
643
|
+
variable,
|
|
644
|
+
point=point,
|
|
645
|
+
terms=terms,
|
|
646
|
+
max_branches=max_branches,
|
|
647
|
+
)
|
|
648
|
+
except FormalBasisError as exc:
|
|
649
|
+
raise LeveltReductionError(str(exc)) from exc
|
|
650
|
+
if not basis.complete:
|
|
651
|
+
return LeveltStructure(
|
|
652
|
+
point=sp.sympify(point),
|
|
653
|
+
local_coordinate=basis.local_coordinate,
|
|
654
|
+
blocks=(),
|
|
655
|
+
basis=basis,
|
|
656
|
+
monodromy=FormalMonodromy(basis, sp.zeros(0), None),
|
|
657
|
+
ramification_index=1,
|
|
658
|
+
complete=False,
|
|
659
|
+
limitation=basis.limitation,
|
|
660
|
+
)
|
|
661
|
+
monodromy = formal_monodromy(basis)
|
|
662
|
+
blocks: list[LeveltExponentialBlock] = []
|
|
663
|
+
offset = 0
|
|
664
|
+
common = 1
|
|
665
|
+
for index, block in enumerate(basis.blocks):
|
|
666
|
+
stop = offset + block.dimension
|
|
667
|
+
cover = monodromy.cover_matrix[offset:stop, offset:stop]
|
|
668
|
+
blocks.append(_block_levelt_data(block, cover, index))
|
|
669
|
+
common = lcm(common, int(block.ramification_index))
|
|
670
|
+
offset = stop
|
|
671
|
+
return LeveltStructure(
|
|
672
|
+
point=sp.sympify(point),
|
|
673
|
+
local_coordinate=basis.local_coordinate,
|
|
674
|
+
blocks=tuple(blocks),
|
|
675
|
+
basis=basis,
|
|
676
|
+
monodromy=monodromy,
|
|
677
|
+
ramification_index=common,
|
|
678
|
+
complete=True,
|
|
679
|
+
)
|