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.
Files changed (49) hide show
  1. odeanalysis/__init__.py +296 -0
  2. odeanalysis/_api_policy.py +179 -0
  3. odeanalysis/_assumptions.py +89 -0
  4. odeanalysis/_block_common.py +106 -0
  5. odeanalysis/_formal_gauge.py +115 -0
  6. odeanalysis/_local.py +166 -0
  7. odeanalysis/_moser.py +271 -0
  8. odeanalysis/_power_simplify.py +32 -0
  9. odeanalysis/_spectral.py +215 -0
  10. odeanalysis/_symbolic_compare.py +16 -0
  11. odeanalysis/_symbolic_errors.py +19 -0
  12. odeanalysis/_zero.py +28 -0
  13. odeanalysis/analytic_continuation.py +343 -0
  14. odeanalysis/bell.py +87 -0
  15. odeanalysis/block_decomposition.py +1144 -0
  16. odeanalysis/canonical.py +471 -0
  17. odeanalysis/certified_continuation.py +160 -0
  18. odeanalysis/diagnostics.py +17 -0
  19. odeanalysis/dominance.py +134 -0
  20. odeanalysis/factorization.py +88 -0
  21. odeanalysis/formal.py +1028 -0
  22. odeanalysis/formal_basis.py +1009 -0
  23. odeanalysis/frobenius.py +349 -0
  24. odeanalysis/fuchsian.py +400 -0
  25. odeanalysis/interchange.py +604 -0
  26. odeanalysis/interoperability.py +143 -0
  27. odeanalysis/irregular.py +250 -0
  28. odeanalysis/kovacic.py +478 -0
  29. odeanalysis/levelt.py +679 -0
  30. odeanalysis/local_analysis.py +386 -0
  31. odeanalysis/local_structure.py +290 -0
  32. odeanalysis/matrix_series.py +357 -0
  33. odeanalysis/newton.py +501 -0
  34. odeanalysis/operator.py +193 -0
  35. odeanalysis/parameter_wkb.py +92 -0
  36. odeanalysis/py.typed +0 -0
  37. odeanalysis/series.py +199 -0
  38. odeanalysis/singularities.py +279 -0
  39. odeanalysis/stokes.py +782 -0
  40. odeanalysis/system.py +347 -0
  41. odeanalysis/system_analysis.py +615 -0
  42. odeanalysis/transition_loci.py +302 -0
  43. odeanalysis/turning.py +516 -0
  44. odeanalysis/wronskian.py +110 -0
  45. odeanalysis-0.1.0.dist-info/METADATA +180 -0
  46. odeanalysis-0.1.0.dist-info/RECORD +49 -0
  47. odeanalysis-0.1.0.dist-info/WHEEL +5 -0
  48. odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
  49. 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
+ )